SFML-owe zabawy #5 - Easing na puzelku


2018-08-22, 00:00

Nasz puzelek nie dość, że mruga - to jeszcze rozciąga się jak żelka! :) Chyba uzależniłem się od tego widoku… Mario::Edit nabiera coraz to lepszych kształtów - sprawdźcie sami!


Uwaga. Wszystko, o czym piszę poniżej znajduje się na branchu iteration-4 w repozytorium projektu, o tutaj: https://github.com/CppPolska/MarioEdit/tree/iteration-4. Jeżeli masz uwagi odnośnie kodu, bądź chcesz dołączyć do projektu - daj znać w komentarzu :)


Co udało się zrobić podczas tej iteracji?

Mamy sukces! :) Zaimplementowałem easing w dokładnie taki sposób, jaki sobie zaplanowałem. Do tego dorzuciłem sporo testów, aby nabrać na pewności, że to działa. Ale wróć! Zacznijmy od początku…

Czym jest easing?

Easing to, w prostych słowach ujmując, sposób na wygładzanie animacji, aby ta była przyjazna dla ludzkiego oka. Dzięki zastosowaniu matematycznej wiedzy o funkcjach jesteśmy w stanie spowodować wrażenie gładkości.

Tam, gdzie jest mowa o animacji, tam również mówi się o czasie. Dokładniej, musimy znać czas, po którym właściwość obiektu zmieni swój stan z wartości początkowej na wartość końcową. Musimy znać również obydwa te stany. Tyle wystarczy, by móc zacząć.

Co możemy poddać wygładzeniu?

Odpowiedź brzmi… wszystko! Dokładniej, wszystko, co możemy zmienić w trakcie trwania animacji. Na pewno może to być rozmiar (wysokość, szerokość, głębokość), odległości (oraz położenia elementów względem osi). Mogą to być również kolory. Wygładzeniu możemy również poddać wartości fizyczne takie jak prędkość, przyśpieszenie, czy ciężar, które wykorzystywane są często w grach.

Zagadnienie teoretyczne

Wyobraźmy sobie sutację: mamy kwadrat znajdujący się w punkcie (10, 1), który chcemy przesunąć na pozycję (3, 1). Czas trwania animacji to jedna sekunda, do wygładzania użyjemy funkcji kwadratowej o wykresie:

f(x) = (x*x)

Zgodnie z naszymi wytycznymi, animacji podlegać będzie tylko pierwsza współrzędna położenia kwadratu.

Odbicie wykresu funkcji

Ponieważ pozycja naszego kwadratu będzie malała, wartości funkcji również muszą maleć. Należy w takim razie odbić wykres względem jej miejsca zerowego (osi X). W takim razie wzór f(x) = x*x musimy zmienić na f(x) = -(x*x) (odbijając wykres względem osi X dodajemy znak minusa do wartości funkcji). Gdybyśmy chcieli odbić wykres względem osi Y, należałoby każde wystąpienie x we wzorze zamienić na -x. Przy funkcji kwadratowej nie ma to jednak najmniejszego sensu, więc zostańmy przy odbiciu względem osi X. Nasz wykres będzie prezentował się w następujący sposób:

Odbicie względem osi X

Przesuwanie wykresu funkcji

Nie zgadza nam się punkt startowy. Ponieważ nasz kwadrat znajduje się w punkcie (10, 1), należy przesunąć wykres funkcji o dziesięć oczek w górę. Wzór naszej funkcji zmieni się na: f(x) = -(x*x)+10.

Gdybyśmy potrzebowali przesunąć nasz wykres o jedno oczko w lewo, musielibyśmy każde wystąpienie x we wzorze funkcji zamienić na x+1. Wzór naszej funkcji wyglądałby wtedy następująco: f(x) = -((x+1)*(x+1))+10. Jeżeli chcielibyśmy przesunąć wykres funkcji w dół, bądź w prawą stronę - należy wykonać te same operacje ze znakiem przeciwnym.

Ponieważ animacja naszego kwadratu rozpocznie się w sekundzie 0, zatem pozostaniemy przy wzorze f(x) = -(x*x)+10. Teraz nasz wykres prezentuje się następująco:

Przeniesienie wykresu 10 oczek w górę

Skalowanie wykresu funkcji

Wszystko nam się powoli klaruje. To, czego nam jeszcze brakuje, to aby funkcja dla x=1 zwróciła wartość 3. Będzie to punkt końcowy animacji. Musimy w takim razie odpowiednio przeskalować wykres wzdłuż osi X. Aby to zrobić, każde wystąpienie x w naszym wzorze musimy zamienić na x/c, gdzie c to dzielnik dający efekt, którego oczekujemy. Ponieważ dzielenie to mnożenie wartości przez odwrotność dzielnika, ja metodą prób i błędów znalazłem mnożnik 2.648, dzięki któremu uzyskam odpowiedni efekt (tak tylko nadmienię, że skalowanie wzdłuż osi Y odbywa się poprzez pomnożenie całej wartości zwracanej przez funkcję przez znaleziony mnożnik). Wzór trochę nam się komplikuje: -((x*2.648)*(x*2.648))+10. Ostatecznie, nasz wykres będzie miał postać:

Finalna wersja funkcji kwadratowej

Wykorzystanie wyznaczonej funkcji

Ostatnim krokiem jest użycie wartości zwracanych przez wyznaczoną przez nas funkcję. Jako argument należy podać czas trwania animacji (zaczynamy od 0, kończymy na 1). Pobierając wartość przykładowo co 50 milisekund, uzyskamy całkiem fajny efekt.

Voilà! Teoretycznie potrafimy już wygładzać animację.

Implementacja easing-u

Moja implementacja easing-u to tak na prawdę… trzy klasy na krzyż. Ponieważ będę wykorzystywał easing w wielu miejscach, zatem na samym początku utworzyłem prostą abstrakcję:

class EasingFunction {

public:

    EasingFunction(sf::Int32 duration, float startValue, float targetValue);

    virtual float getValue(sf::Int32 time)=0;

protected:

    sf::Int32 duration = 1000;

    float startValue = 0.0f;
    float targetValue = 1.0f;

};

Tutaj mamy wszystko, o czym pisałem we wstępie teoretycznym - długość trwania animacji, początkową wartość oraz wartość końcową. Dodatkowo, każda klasa rozszerzająca tą abstrakcję będzie musiała zaimplementować metodę getValue(...), która to zwróci wartość funkcji w czasie.

W sumie zaimplementowałem trzy funkcje:

  • LinearFunction - jako funkcja liniowa y=x
  • QuadraticFunction - jako funkcja kwadratowa y=x*x
  • LogaritmicFunction - jako funkcja logarytmiczna y=log(x)+3.

Przyjżyjmy się analizowanej poprzednio funkcji kwadratowej:

float QuadraticFunction::getValue(sf::Int32 time) {
    float timePart = (float)time/(float)duration;
    float value = timePart*timePart;
    value *= targetValue-startValue;
    value += startValue;
    return value;
}

Coś tutaj nie gra… przecież tutaj prawie nie ma kodu! No tak, nie ma go zbyt wiele :) Zgodnie z regułą SRP, ta klasa ma robić tylko jedną rzecz, a dobrze. Zatem jak możemy zauważyć:

  1. Dzielę czas, aby ten był z zakresu [0..1]
  2. Obliczam wartość funkcji na podstawie obliczonej wartości
  3. Skaluję wykres wzdłuż osi Y
  4. Przesuwam wykres również wzdłuż osi Y.

Nic skomplikowanego. Ciekawie robi się w momencie składania animacji. Dla przykładu przeanalizujmy sobie animację dla wydarzenia MouseEnter:

HightlightTileAnimation::HightlightTileAnimation(Tile* tile) {
    this->tile = tile;
    this->sleepTime = 10;
    this->duration = 300;
}

void HightlightTileAnimation::run() {
    isRunningFlag = true;

    sf::Clock clock;
    sf::Int32 startMilliseconds = clock.getElapsedTime().asMilliseconds();

    sf::Int32 duration = this->duration;
    sf::Int32 sleepTime = this->sleepTime;
    Tile* tile = this->tile;

    std::thread interval([=]() mutable {
        sf::Int32 animationPointInTime = 0;

        float finishScalePromotion = 1.8f;
        sf::Int32 duration1 = duration/3*2;
        LogarithmicFunction function(duration1, tile->scalePromotion, finishScalePromotion);

        do {
            if (isStopped) {
                break;
            }

            sf::Int32 currentMilliseconds = clock.getElapsedTime().asMilliseconds();
            animationPointInTime = currentMilliseconds-startMilliseconds;
            if (animationPointInTime > duration1) {
                animationPointInTime = duration1;
            }

            tile->scalePromotion = function.getValue(animationPointInTime);
            tile->rescaleCenter();
            tile->correctCorners();

            std::this_thread::sleep_for(std::chrono::milliseconds(sleepTime));
        } while (animationPointInTime < duration1 || isStopped);

        finishScalePromotion = 1.5f;
        startMilliseconds = clock.getElapsedTime().asMilliseconds();

        sf::Int32 duration2 = duration/3;
        LogarithmicFunction function2(duration2, tile->scalePromotion, finishScalePromotion);

        do {
            if (isStopped) {
                break;
            }

            sf::Int32 currentMilliseconds = clock.getElapsedTime().asMilliseconds();
            animationPointInTime = currentMilliseconds-startMilliseconds;
            if (animationPointInTime > duration2) {
                animationPointInTime = duration2;
            }

            tile->scalePromotion = function2.getValue(animationPointInTime);
            tile->rescaleCenter();
            tile->correctCorners();

            std::this_thread::sleep_for(std::chrono::milliseconds(sleepTime));
        } while (animationPointInTime < duration2 || isStopped);

        isRunningFlag = false;
        isStopped = false;
    });
    interval.detach();
}

No. Tu mamu już spory kawałek kodu. Pierwsze co widać (i co kwalifikuje się w pierwszej kolejności do poprawy) to powtórzenie kodu. Postanowiłem, że animacja wyróżnienia puzelka będzie składała się z dwóch etapów. Najpierw puzelek powiększy się do poziomu 180% swojego oryginalnego rozmiaru, a następnie skurczy się do poziomu 150%. Takie rozwiązanie daje efekt rozciągającego się żelka, który bardzo mi się podoba. Dodatkowo, możemy zauważyć - że całość animacji (podobnie jak mruganie) uruchamiamy w osobnym wątku. W tym przypadku zdecydowałem się na to z powodu zwiększenia czytelności kodu.

Pamiętajmy: w dodatkowym wątku obliczamy jedynie wartości istniejących w pamięci obiektów. Rysowanie w dalszym ciągu odbywa się w głównym wątku programu.

Ostatnim miejscem do omówienia jest punkt zaczepu animacji, czyli funkcja uruchamiająca się przy wydarzeniu MouseEnter:

void Tile::hightlight() {
    if (undoHighlightTileAnimation->isRunning()) {
        undoHighlightTileAnimation->stop();
    }
    if (!highlightTileAnimation->isRunning()) {
        highlightTileAnimation->run();
    }
}

Animacje highlightTileAnimation oraz undoHighlightTileAnimation nie mogą działać równocześnie, ponieważ są swojego rodzaju przeciwnościami. W takim razie, jeżeli ktoś szybko zjechał kursorem myszy z puzelka i równie szybko wjechał na niego z powotem, musimy zatrzymać animację undoHighlightTileAnimation. Moja implementacja zatrzymuje tą animację w miejscu, zatem wyróżnianie puzelka rozpocznie się od tej wielkości puzelka, na której zakończyła się poprzednia animacja.

To wszytko! Gdyby nie (niepotrzbne) powtórzenie kodu, to na prawdę mało byłoby go na listingach. Przejdźmy do następnego punktu, mianowicie do problemów, z którymi spotkałem się podczas implementowania animacji.

Asynchroniczność i jej narzut

Asynchroniczność to błogosławieństwo. Przy dobrze napisanej aplikacji zyskujemy na czytelności, a niejednokrotnie również na wydajności aplikacji¹. Niestety, oprócz tych kilku zalet, asynchroniczność ma również wady. Sam w bierzącej iteracji nadziałem się trzykrotnie na pułapki, które dały mi sporo do myślenia.

Upewnij się, że dane na których pracujesz nie zostały usunięte

No właśnie. Najgłupsze, co mogłem zrobić. Przyjżyjmy się poniższemu przykładowi:

void Tile::hightlight() {
    HightlightTileAnimation highlightTileAnimation(...);
    highlightTileAnimation->run();              // <---- Tutaj odpalamy nowy wątek
}

Na pierwszy rzut oka wszystko git, nie? No właśnie… nie! Popatrzmy, co tutaj się dzieje:

  1. Tworzymy obiekt animacji.
  2. Uruchamiamy wątek w funkcji run()
  3. Koniec funkcji, uruchamia się destruktor obiektu animacji
  4. Bardzo prawdopodobny crash.

U mnie akurat crash był. Dlaczego? Ponieważ w nowym wątku korzystałem z danych znajdujących się wewnątrz obiektu highlightTileAnimation, który… się usunął. Akurat proces jakimś cudem dalej miał prawa do odczytu tego fragmentu pamięci, ale były tam już same śmieci. Ten problem udało mi się rozwiązać przez wrzucenie obiektu highlightTileAnimation jako właściwość klasy Tile. W końcu, nie można animować puzla, którego nie ma :)

Upływ czasu nie zawsze taki, jaki chcemy

Drugim problemem, z którym się spotkałem było to, że mimo testów (które utwierdziły mnie w tym, że funkcja dobrze liczy) w animacji były niedociągnięcia, i na końcu puzelek odstawał o jeden piksel od siatki, albo wystawał jeden piksel poza siatkę. Dlaczego?

Z grubsza mój kod tworzenia wątku wyglądał tak:

    std::thread interval([=]() {
        sf::Int32 animationPointInTime = 0;

        while (animationPointInTime <= duration) {
            sf::Int32 currentMilliseconds = clock.getElapsedTime().asMilliseconds();
            animationPointInTime = currentMilliseconds-startMilliseconds;

            // Do something...

            std::this_thread::sleep_for(std::chrono::milliseconds(sleepTime));
        }
    });
    interval.detach();

Na pierwszy rzut oka znowu kod wygląda dobrze. Niestety. Problemem okazało się tutaj moje myślenie. Zapomniałem, jak działa system przerwań. Mianowicie: to, że ja proszę wątek o stop na np. 20 milisekund, to nie znaczy że dokładnie te 20 milisekund minie. Może minąć 21 milisekund i dopiero potem wątek obudzi się. W konsekwencji, z tego powodu pętla omijała ostatni obrót, a puzelek nie uzyskał pełnego rozmiaru. Kod naprawiłem przez zastosowanie pętli do..while, która daje mi gwarancję tego ostatniego obrotu. Zatem, teraz mam mniej wiecej coś takiego:

    std::thread interval([=]() {
        sf::Int32 animationPointInTime = 0;

        do {
            sf::Int32 currentMilliseconds = clock.getElapsedTime().asMilliseconds();
            animationPointInTime = currentMilliseconds-startMilliseconds;

            // Do something...

            std::this_thread::sleep_for(std::chrono::milliseconds(sleepTime));
        } while (animationPointInTime < duration);
    });
    interval.detach();

Zatrzymywanie animacji

Dosyć śmiesznym jest to, że nie przewidziałem tego, że animacje wydarzeń MouseEnter i MouseLeave mogą się nałożyć przy zbyt szybkim machaniu kursorem myszy. Trzeba było w takim razie zareagować. W momencie kiedy wchodzi drugie wydarzenie, a animacja jeszcze trwa, należało zastopować poprzednią animację. Efekt jest dosyć ładny - kiedy animacja dla MouseEnter nie skończy się wykonywać (a nastąpiło wydarzenie MouseLeave), to animacja dla wydarzenia MouseLeave rozpocznie z tego samego miejsca, co ta pierwsza skończyła.

Czego oko nie widzi… tam zrób testy!

Implementacja funkcji wykonujących matematyczne obliczenia to bardzo dobry przykład kodu, który warto opatrzeć testami. Tak na prawdę, to testy dały mi pewność, że wszystko liczy się dobrze. To testy powiedziały mi, że funkcja wychodzi poza ustalone przeze mnie granice i należy ją skorygować. To testy poinformowały mnie o tym, że funkcja logarytmiczna przy zerze osiąga wartość ujemnej nieskończoności. Dodatkowo, dzięki testom przekonałem się, że mój kod realizujący easing działa również… odwrotnie! :)

Jestem wielkim fanem pisania testów, ale należy znać umiar. Łatwo jest się niestety zabetonować testami. Mi wystarczyło przetestować każdą z funkcji easing-owych w trzech (maksymalnie czterech) scenariuszach. Poniżej zamieszczam jeden przykładowy scenariusz (dla potomnych):

LogarithmicFunction createLogarithmicPositiveToPositiveFunction() {
    LogarithmicFunction function(1500, 1.0f, 2.0f);
    return function;
}

TEST(EasingFunction_LogarithmicFunction_PositiveToPositive_Test, test_0percent_value) {
    auto function = createLogarithmicPositiveToPositiveFunction();
    ASSERT_FLOAT_EQ(1.0014225f, function.getValue(0));
}

TEST(EasingFunction_LogarithmicFunction_PositiveToPositive_Test, test_25percent_value) {
    auto function = createLogarithmicPositiveToPositiveFunction();
    ASSERT_FLOAT_EQ(1.5379019f, function.getValue(375));
}

TEST(EasingFunction_LogarithmicFunction_PositiveToPositive_Test, test_50percent_value) {
    auto function = createLogarithmicPositiveToPositiveFunction();
    ASSERT_FLOAT_EQ(1.7689509f, function.getValue(750));
}

TEST(EasingFunction_LogarithmicFunction_PositiveToPositive_Test, test_75percent_value) {
    auto function = createLogarithmicPositiveToPositiveFunction();
    ASSERT_FLOAT_EQ(1.9041059f, function.getValue(1125));
}

TEST(EasingFunction_LogarithmicFunction_PositiveToPositive_Test, test_100percent_value) {
    auto function = createLogarithmicPositiveToPositiveFunction();
    ASSERT_FLOAT_EQ(2.0f, function.getValue(1500));
}

Efekt

Jest obłędnie :) Gif nie odwzorowywuje tego tak płynnie, jak to ma miejsce bezpośrednio w aplikacji. Jeżeli macie ochotę sprawdzić ten efekt na żywo - wystarczy pobrać kod z repozytorium i go skompilować :)

Żelkowy easing

Plany na kolejną iterację

Na następny raz przygotuję belkę z kilkoma nowymi puzelkami i zmienię odrobinę sposób klikania na puzelek. Mam pomysł na to, jak umożliwić szybkie generowanie terenu graczom. Oby się udało :) Całkiem możliwe, że dorzucę też jakieś wzgórza i chmurki, żeby było ładniej.

Do przeczytania następnym razem! :)


¹ Nie, nie zawsze im więcej wątków, tym lepiej ;)



Marcin Kukliński

Zawodowo backend developer, hobbystycznie pasjonat języka C++. Po godzinach poszerza swoją wiedzę na takie tematy jak teorii kompilacji oraz budowa formatów plików. Jego marzeniem jest stworzyć swój własny język programowania.

Pssst! Używamy Cookies. Poprzez używanie naszego serwisu zgadzasz się na odczytywanie i zapisywanie Cookies w swojej przeglądarce.