Płynny ruch postaci w grach 2D w Javie: pętle gry i delta time

0
74
Rate this post

Dlaczego ruch postaci „rwie” i skacze w prostych grach 2D

Charakterystyczne objawy niepoprawnej pętli gry

Nierówny, „rwany” ruch postaci w grach 2D w Javie zwykle widać już po kilku minutach testów. Postać jedzie płynnie, po czym nagle jakby przeskoczy o kilka pikseli, wykonuje krótki „teleport”, albo cała scena dostaje subtelnych mikroprzycięć. Na słabszym laptopie gra może wydawać się ociężała i wolna, a na szybszym komputerze ten sam kod zamienia się w przyspieszoną wersję, gdzie ruchy są zbyt gwałtowne.

Źródło tego efektu rzadko leży w „za wolnym komputerze”. Zazwyczaj winny jest sposób zaprojektowania pętli gry i użycia czasu. Jeśli logika ruchu jest zapisana w stylu „przesuń o 5 pikseli na klatkę”, to przy 30 FPS postać będzie poruszać się dwa razy wolniej niż przy 60 FPS. Podobnie, jeśli pętla opiera się tylko na prostym Thread.sleep(16), a system operacyjny czy JVM inaczej planują wątki, rzeczywiste odstępy czasu między klatkami mogą być dalekie od stabilnych 16 ms.

Drugi rodzaj objawów to „przeskakiwanie animacji”. Jeśli animacje są powiązane z liczbą klatek, np. „co 5 klatek zmień klatkę animacji”, to przy spadku FPS animacja zwolni, a przy skoku FPS – przyspieszy. Daje to dziwny, nieprzewidywalny odbiór gry, który trudno później poprawić samymi klatkami sprite’ów czy zmianą grafik.

Wiązanie logiki gry ze stałym FPS: klasyczny błąd

Bardzo popularny schemat w pierwszych projektach to założenie: „moja gra działa w 60 FPS, więc po prostu co klatkę przesuwam obiekt o X pikseli”. Problem w tym, że w realnym świecie FPS jest skutkiem, a nie gwarantowanym parametrem. Nikt nie zagwarantuje, że gra utrzyma idealne 60 FPS. Gdy obciążenie wzrośnie, pojawią się sekundy, w których klatek będzie mniej. Gdy ktoś odpali grę na szybszym CPU z mocną kartą graficzną, klatek będzie więcej niż zakładałeś.

Jeśli ruch jest policzony „na klatkę”, a nie „na sekundę”, to fizycznie ta sama postać poruszy się w ciągu jednej sekundy ekranu o inną odległość w zależności od liczby klatek. W efekcie logika gry przestaje być deterministyczna. Dwie osoby grające w ten sam tytuł, na innych komputerach, doświadczają innych czasów skoków, innych prędkości pocisków i nieco innych zachowań fizyki.

Takie podejście szczególnie boleśnie odbija się w grach platformowych lub tam, gdzie jest dużo elementów fizycznych (grawitacja, inercja, kolizje z platformami). Gdy liczba klatek spada, postać może nagle nie doskoczyć do półki, bo w danej sekundzie wykonała mniej aktualizacji pozycji. Z kolei po podniesieniu FPS te same skoki stają się „zbyt mocne”, przez co balans gry rozpada się na kawałki.

Dlaczego samo Thread.sleep(16) i repaint() nie wystarczają

Standardowy, „naiwny” przykład pętli gry w Javie wygląda mniej więcej tak:

while (running) {
    update();
    render();
    try {
        Thread.sleep(16); // ~60 FPS
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

Na papierze wygląda to rozsądnie: 16 ms na klatkę oznacza około 60 klatek na sekundę. Jednak Thread.sleep nie gwarantuje dokładności co do milisekundy. System może uśpić wątek na dłużej, a JVM może wstrzymać wątek np. z powodu zbierania śmieci (GC). W praktyce jedna klatka zajmie 18 ms, inna 25 ms, a jeszcze inna 14 ms. Z poziomu logiki gry każda z tych klatek jest jednak traktowana identycznie: przesuwamy obiekt o tę samą wartość.

Drugim problemem jest powiązanie update() i render() w jednym cyklu z jednym sleep(). Jeśli w danym momencie scena jest bardziej złożona do narysowania, renderowanie zajmie więcej czasu, więc cały krok gry stanie się dłuższy. Logika gry jest zatem zależna nie tylko od mocy CPU, ale też od tego, ile rzeczy próbujemy narysować i jak skuteczne są nasze optymalizacje grafiki.

Efekt: na jednym komputerze, przy danej scenie, gra będzie miała mniej więcej 60 FPS i w miarę stałą dynamikę, na innym – np. przy włączonym antywirusie, innym systemie, gorszej karcie graficznej – ten sam schemat pętli da 40 FPS, a ruch i animacje zwolnią o jedną trzecią.

Ilustracyjny przykład różnej prędkości gry na różnych maszynach

Wyobraźmy sobie prostą grę 2D, w której postać ma przesuwać się z lewej strony ekranu na prawą w ciągu około 3 sekund. Programista założył 60 FPS i wpisał w kod:

int speedPerFrame = 5; // pikseli na klatkę
x += speedPerFrame;

Na laptopie, który realnie renderuje ~60 FPS, wszystko wygląda poprawnie. W ciągu sekundy postać przesuwa się o około 300 pikseli, więc droga trwa ok. 3 sekundy. Na mocnym PC, gdzie pętla jest w stanie wycisnąć np. 120 FPS, ta sama postać w ciągu sekundy wykona 120 aktualizacji po 5 pikseli, czyli łącznie przeleci 600 pikseli – droga skróci się o połowę. W praktyce gra sprawia wrażenie przyspieszonej.

Z kolei na słabszej maszynie, gdzie z powodu obciążenia pętla dobija tylko do ~30 FPS, ten sam kod przeniesie postać zaledwie o około 150 pikseli na sekundę, co sprawi, że ruch będzie dwa razy wolniejszy. Użytkownik ma wrażenie, że gra „laguje” albo jest źle zoptymalizowana, choć faktyczny problem leży w braku powiązania ruchu z rzeczywistym czasem.

Programista w czarnej bluzie analizuje dane na kilku monitorach
Źródło: Pexels | Autor: Tima Miroshnichenko

Podstawy pętli gry – co musi się wydarzyć w każdej klatce

Elementarne kroki w pętli gry

Każda sensowna pętla gry – niezależnie od języka czy biblioteki – wykonuje w każdej iteracji trzy podstawowe kroki:

  • Odczyt wejścia (input) – pobranie aktualnego stanu klawiatury, myszy, pada.
  • Aktualizacja logiki (update) – modyfikacja stanu świata gry na podstawie wejścia i upływu czasu.
  • Renderowanie (render / draw) – narysowanie aktualnego stanu gry na ekranie.

Te trzy elementy powinny być w kodzie wyraźnie rozdzielone. Nawet jeśli na początku program jest mały i kuszące wydaje się łączenie wszystkiego w jednym miejscu, taki nawyk zemści się przy pierwszej próbie rozszerzenia gry o bardziej złożoną fizykę, animacje czy sieć.

Odczyt wejścia oznacza nie tylko sprawdzanie, czy klawisz został właśnie wciśnięty, ale też przechowywanie stanu: „klawisz A jest aktualnie wciśnięty”. Logika aktualizacji powinna odwoływać się do tego stanu, a nie bezpośrednio do zdarzeń. Renderowanie – z kolei – nie powinno (poza rzadkimi wyjątkami) zmieniać stanu gry, tylko go odczytywać.

Oddzielenie logiki gry od renderowania

Logika gry i renderowanie często mają inne wymagania co do częstotliwości odświeżania. Logika może spokojnie pracować w 60 krokach na sekundę, podczas gdy renderowanie, zwłaszcza przy szybkich monitorach, może celować w 120 FPS. Z drugiej strony, na słabszym sprzęcie logika powinna pozostać stabilna nawet wtedy, gdy renderowanie nie wyrabia z odświeżaniem każdej klatki.

Jeśli logika i render są ze sobą „sklejone” – czyli za każdym razem, gdy wywołujemy render(), update() wykonuje się dokładnie raz – zaczyna się walka z wydajnością. W sytuacji, gdy renderowanie zacznie trwać dłużej (np. dodamy więcej sprite’ów, oświetlenie, efekty cząsteczkowe), logika gry też zostanie spowolniona. To dlatego powstają koncepcje takie jak fixed timestep i interpolacja, w których logika jest wykonywana ze stałą częstotliwością, a renderowanie – tak często, jak się da.

Rozdzielenie logiki i renderowania pomaga również przy testowaniu. Logika gry, zaimplementowana np. jako czyste metody update(dt), może być testowana jednostkowo, bez konieczności odpalania pełnego renderera. Daje to większą kontrolę nad zachowaniem gry i ułatwia refaktoryzację.

Game loop vs aplikacje GUI oparte na zdarzeniach

Klasyczne aplikacje desktopowe w Javie, oparte na Swingu czy JavaFX, działają głównie w oparciu o pętlę zdarzeń (event loop), którą zarządza framework. Aplikacja rejestruje listenery na zdarzenia (kliknięcia, zmiany rozmiaru okna, itp.), a biblioteka GUI wywołuje te listenery, gdy coś się wydarzy. Programista rzadko ma kontrolę nad główną pętlą.

W grach sytuacja jest inna. Świat gry „biegnie” cały czas: nawet jeśli gracz nic nie robi, obiekty mogą się poruszać, przeciwnicy decydować o ruchu, a animacje – przewijać. Dlatego w grach 2D w Javie trzeba zaprojektować własną pętlę gry, która w powtarzalny sposób wywołuje aktualizację i renderowanie.

Używanie samego repaint() w Swingu jako jedynego wyzwalacza odświeżania logiki prowadzi do utraty kontroli nad tempem gry. Swing decyduje, kiedy i jak często przerysować komponent. Bez własnej pętli nikt nie gwarantuje, że logika gry działa w powtarzalnych odstępach czasu, co praktycznie uniemożliwia stabilny ruch postaci i poprawne wykorzystanie delta time.

Konsekwencje braku własnej pętli gry

Brak własnej pętli gry oznacza uzależnienie tempa całej rozgrywki od mechanizmów biblioteki GUI i systemu operacyjnego. To może jeszcze „użyć się” w prostym edytorze kafelków, ale przy grze akcji każda nieregularność w wywoływaniu aktualizacji będzie natychmiast widoczna. Pojawiają się zacięcia, nierówny ruch, nieliniowo zmieniający się czas reakcji.

Dodatkowo, bez precyzyjnej pętli trudniej wprowadzić takie koncepcje, jak delta time, fixed timestep czy interpolację. Każda większa zmiana w logice gry zaczyna wymagać kombinowania z timerami Swinga,(AnimationTimer w JavaFX) lub dziwnymi harmonogramami wątków, zamiast czytelnej, własnej pętli, która w jednym miejscu zarządza czasem.

Monitor z zielonym kodem na ekranie w ciemnym pomieszczeniu
Źródło: Pexels | Autor: Tima Miroshnichenko

Czas w grach 2D: FPS, delta time i timestep – uporządkowanie pojęć

FPS jako skutek, nie cel

Frames Per Second (FPS) mówi, ile razy na sekundę gra jest w stanie wyrenderować nową klatkę. Jest to miara wydajności, ale nie powinien to być bezpośredni parametr sterujący logiką gry. Zdrowe podejście zakłada, że gra dąży do utrzymania pewnej częstotliwości aktualizacji logiki (np. 60 kroków na sekundę), ale prawdziwym zegarem jest czas, a nie liczba klatek.

Jeśli myśli się o FPS jako o celu („moja gra musi mieć 60 FPS”), często kończy się to związywaniem ruchu i fizyki z tą samą wartością. W rezultacie przy dowolnym odchyleniu od zakładanych FPS gra zachowuje się inaczej niż na maszynie dewelopera. Dużo stabilniejszym podejściem jest projektowanie logiki w jednostkach „na sekundę”, a FPS traktowanie jak parametru jakości wizualnej.

Delta time – co to dokładnie jest i co mierzyć w Javie

Delta time (często skracane do dt) to czas, który upłynął między dwoma kolejnymi aktualizacjami logiki gry. Jeśli poprzednia iteracja pętli logiki skończyła się w chwili t1, a bieżąca kończy w chwili t2, to:

dt = t2 - t1

W Javie do pomiaru czasu w grach 2D zwykle używa się System.nanoTime(). Zwraca on czas w nanosekundach, w miarę monotoniczny i niezależny od zmian zegara systemowego. Różnica dwóch odczytów:

long now = System.nanoTime();
long deltaNanos = now - lastTime;

daje dokładny czas, który można przeliczyć na sekundy, milisekundy lub inne jednostki. W kontekście pętli gry przydaje się postać w sekundach, bo wtedy prędkości i przyspieszenia można wyrażać w czytelnych jednostkach (piksele na sekundę, piksele na sekundę do kwadratu).

Fixed timestep vs variable timestep – ogólny obraz

W uproszczeniu istnieją dwa główne style zarządzania czasem w pętli gry:

  • Variable timestep – w każdej iteracji obliczasz dt jako czas od poprzedniej aktualizacji i przekazujesz go do logiki: update(dt). Krok czasowy jest zmienny.
  • Fixed timestep – logika gry zakłada stały krok czasowy (np. 1/60 sekundy). Czas jest akumulowany i logika działa w „porcjach”, które mają regularną długość w czasie gry, nawet jeśli rzeczywisty czas systemowy jest nieregularny.

Variable timestep bywa polecany jako „prostszy na start”: single-thread, jedno wywołanie update(dt) na obrót pętli, brak akumulacji czasu. Problem zaczyna się przy skokach wydajności – gdy jedna klatka z jakiegoś powodu trwa znacznie dłużej, dt rośnie, a logika fizyki dostaje nagły „kopniak w czas”. Przy zderzeniach, skokach postaci czy precyzyjnych sterowaniach takie fluktuacje bardzo szybko wyłażą na wierzch, bo ruch przestaje być przewidywalny.

Fixed timestep rozwiązuje ten kłopot, ale dorzuca inne: trzeba akumulować czas, wykonywać pętlę aktualizacji wielokrotnie, gdy system się przytnie, i jednocześnie nie pozwolić, by logika wykonywała się w nieskończoność, gdy renderowanie nie nadąża. Dochodzi też interpolacja pozycji pomiędzy krokami logiki, aby uniknąć „schodkowego” ruchu na ekranie. To konstrukcyjnie trudniejsze niż naiwne update(dt), ale za to daje spójny model czasu gry, który nie rozpada się przy zmianie FPS.

Rozsądny kompromis w prostych grach 2D wygląda często tak: zaczynasz od variable timestep, ale z klamrowaniem dt (np. nie większym niż 1/30 s) i z myślą, że część logiki – zwłaszcza kolizje i fizyka – przeniesiesz później na stały krok. Gdy gra zaczyna wymagać większej precyzji, dodajesz fixed timestep dla krytycznej warstwy (np. świata fizycznego), a renderowanie i mniej ważne systemy dalej korzystają z bieżącego czasu i interpolacji.

Przy większych projektach lepiej od razu zainwestować w pełny model: logika na stałym kroku (np. 60 Hz), odrębne, tak częste, jak się da, renderowanie oraz interpolacja pozycji na podstawie dwóch ostatnich stanów świata gry. To nie jest „przedwczesna optymalizacja”, tylko świadome uporządkowanie czasu w aplikacji, które później pozwala bezboleśnie zmieniać szczegóły: dodać animacje, rozbudować sterowanie, podpiąć sieć albo wpiąć nową bibliotekę renderującą, nie ruszając fundamentów ruchu postaci.

Jeżeli pętla gry jasno rozdziela czas, logikę i renderowanie, a ruch opiera się na jednoznacznie zdefiniowanym delta time lub stałym kroku, wtedy zmiana sprzętu, rozdzielczości czy docelowego FPS nie przewraca całej gry do góry nogami – zostaje tylko kwestią tego, jak często można sobie pozwolić na narysowanie kolejnej klatki.

Przykładowa pętla variable timestep w Javie – od naiwnej do używalnej

Najprostszy możliwy kod pętli często wygląda tak:

boolean running = true;
long lastTime = System.nanoTime();

while (running) {
    long now = System.nanoTime();
    double dt = (now - lastTime) / 1_000_000_000.0;
    lastTime = now;

    update(dt);
    render();
}

Dopóki gra jest lekka, a sprzęt wydajny, ten wariant będzie „jakoś” działał. Problem zaczyna się w momencie, gdy jedna iteracja nagle trwa więcej niż zwykle – na przykład system uruchomi antywirusa, gracz przeciągnie okno albo zewnętrzna biblioteka wstrzyma na chwilę wątek. Wtedy dt skacze z ułamków sekundy do kilku, a nawet kilkunastu dziesiątych. Jeżeli logika ruchu jest liniowo zależna od dt, dostaje gigantyczny skok.

Klasyczna rada: „po prostu przemnażaj wszystko przez dt” działa, dopóki dt jest względnie stabilne. Przy jednorazowych skokach zmienia się w broń obosieczną. W praktyce wypada więc od razu wprowadzić ograniczenie:

double MAX_DT = 1.0 / 30.0; // maksymalnie 1/30 s na aktualizację

while (running) {
    long now = System.nanoTime();
    double dt = (now - lastTime) / 1_000_000_000.0;
    lastTime = now;

    if (dt > MAX_DT) {
        dt = MAX_DT;
    }

    update(dt);
    render();
}

To nie ratuje gry przy chronicznym braku wydajności (gdy każda klatka trwa trzy razy za długo), ale zabezpiecza przed pojedynczymi wyskokami – logika dostaje wtedy kilka mniejszych kroków zamiast jednego ogromnego. Przy grach 2D z prostą fizyką taki na wpół „gumowy” model czasu bywa zupełnie wystarczający.

Taka pętla ma jednak inną ukrytą cenę: brak jakiejkolwiek kontroli nad docelową częstotliwością aktualizacji. Na szybkich maszynach potrafi robić setki iteracji na sekundę, co jest zbędne. Nicość między iteracjami wypełnia ciepło wentylatora. Jeżeli gra nie wymaga superresponsywnego sterowania, prosty limit FPS rozwiązuje sporo problemów.

final double TARGET_FPS = 120.0;
final double TARGET_FRAME_TIME = 1.0 / TARGET_FPS;

while (running) {
    long start = System.nanoTime();

    long now = System.nanoTime();
    double dt = (now - lastTime) / 1_000_000_000.0;
    lastTime = now;

    if (dt > MAX_DT) dt = MAX_DT;

    update(dt);
    render();

    double frameTime = (System.nanoTime() - start) / 1_000_000_000.0;
    while (frameTime < TARGET_FRAME_TIME) {
        Thread.yield();
        frameTime = (System.nanoTime() - start) / 1_000_000_000.0;
    }
}

To nadal jest variable timestep, ale z zarysowanym sufitem na częstotliwość. Na małym laptopie gra nie „zabije” baterii, na mocnej stacji nie wkręci się niepotrzebnie na setki tysięcy iteracji.

Implementacja fixed timestep z akumulatorem czasu

Przy przejściu na stały krok logiki struktura pętli się zmienia. Najczęściej stosuje się akumulator czasu, który zbiera opóźnienia i wykonuje logikę w porcjach o równym kroku:

final double STEP = 1.0 / 60.0; // 60 Hz logiki
boolean running = true;
double accumulator = 0.0;

long lastTime = System.nanoTime();

while (running) {
    long now = System.nanoTime();
    double frameTime = (now - lastTime) / 1_000_000_000.0;
    lastTime = now;

    // zabezpieczenie przed gigantycznymi skokami
    if (frameTime > 0.25) {
        frameTime = 0.25;
    }

    accumulator += frameTime;

    while (accumulator >= STEP) {
        update(STEP);
        accumulator -= STEP;
    }

    render();
}

Kluczowa różnica: logika nie „wie” o realnym dt. Funkcja update() zawsze dostaje ten sam krok STEP. Jeżeli jedna klatka się opóźni, pętla nadgoni to, wykonując kilka wywołań update(STEP) pod rząd. Świat gry porusza się stałym tempem, niezależnie od tempa renderowania (dopóki średnio nadążamy).

Popularna rada w podręcznikach brzmi: „po prostu odpalaj update w pętli, aż akumulator się wyzeruje”. Brzmi rozsądnie, dopóki gra nie wpadnie w spiralę śmierci – renderowanie zwalnia, więc logika próbuje gonić, więc całość jeszcze bardziej zwalnia. Zabezpieczenie przed tym jest proste:

final int MAX_UPDATES_PER_FRAME = 5;

int updates = 0;
while (accumulator >= STEP && updates < MAX_UPDATES_PER_FRAME) {
    update(STEP);
    accumulator -= STEP;
    updates++;
}

Po przekroczeniu limitu logika „oddaje” część czasu – gra zaczyna działać „w zwolnionym tempie”, zamiast kompletnie się zatrzymać. To nie jest idealne z punktu widzenia symulacji, ale praktyczne z perspektywy gracza: lepiej mieć wolniejszą, ale reagującą grę niż pokaz slajdów z ogromnym input lagiem.

Interpolacja pozycji przy stałym kroku logiki

Stały krok logiki powoduje inny efekt uboczny: gdy renderowanie działa z wyższą częstotliwością niż logika, obiekty „przeskakują” między kolejnymi stanami. Zamiast płynnego przesuwania się o kilka pikseli na klatkę, widzimy, jak postać skacze z punktu do punktu. Rozwiązaniem jest interpolacja między ostatnim a następnym stanem.

Do tego potrzebne są przynajmniej dwie kopie stanu logicznego: poprzednia i bieżąca. Jedna z prostszych struktur może wyglądać tak:

class EntityState {
    double x, y;
    double vx, vy;
}

EntityState previousState = new EntityState();
EntityState currentState  = new EntityState();

W każdej aktualizacji kopiujemy bieżący stan do previousState, następnie modyfikujemy currentState:

void simulate(double dt) {
    // logika fizyki, sterowanie, AI...
    currentState.x += currentState.vx * dt;
    currentState.y += currentState.vy * dt;
}

void update(double dt) {
    previousState.x = currentState.x;
    previousState.y = currentState.y;
    previousState.vx = currentState.vx;
    previousState.vy = currentState.vy;

    simulate(dt);
}

Renderowanie dostaje dodatkowy parametr – współczynnik alpha, określający, jak „daleko” w czasie jesteśmy pomiędzy dwoma krokami logiki:

double alpha = accumulator / STEP;
render(alpha);

W środku render(alpha) można wyliczyć pozycję obiektu w oparciu o liniową interpolację:

double renderX = previousState.x + (currentState.x - previousState.x) * alpha;
double renderY = previousState.y + (currentState.y - previousState.y) * alpha;

// narysuj sprite pod (renderX, renderY)

Dzięki temu nawet jeśli logika tyka w 60 Hz, a renderowanie w 144 Hz, ruch na ekranie pozostaje płynny. Obiekt „płynie” po prostej między dwoma stanami logiki, a nie przeskakuje między nimi. W prostych grach 2D interpolacja liniowa jest najczęściej w zupełności wystarczająca.

Zdarza się, że ktoś próbuje „dopisać” interpolację jako kosmetyk na końcu projektu – w praktyce szybciej jest od razu podeprzeć pętlę dwoma stanami. Późniejsze wplatanie previousState między istniejące struktury bywa bardziej bolesne niż zaprojektowanie tego od początku.

Model ruchu postaci oparty o delta time

Niezależnie od wariantu pętli punkt wspólny jest taki sam: logika ruchu zna pojęcie prędkości w jednostkach „na sekundę”. Przykładowo, prosta postać sterowana klawiszami:

class Player {
    double x, y;
    double speed = 200.0; // piksele na sekundę

    boolean left, right, up, down;

    void update(double dt) {
        double dx = 0.0;
        double dy = 0.0;

        if (left)  dx -= 1.0;
        if (right) dx += 1.0;
        if (up)    dy -= 1.0;
        if (down)  dy += 1.0;

        // normalizacja, żeby ruch po przekątnej nie był szybszy
        double len = Math.sqrt(dx*dx + dy*dy);
        if (len > 0.0) {
            dx /= len;
            dy /= len;
        }

        x += dx * speed * dt;
        y += dy * speed * dt;
    }
}

Ten wzorzec (stan wejścia –> wektor kierunku –> przeliczenie na prędkość –> przemieszczenie speed * dt) można powielać w całej grze. Zmiana jednostek (piksele na sekundę, metry na sekundę w świecie fizyki, dowolna inna skala) nie wymusza przebudowy pętli, tylko zmianę kilku stałych.

Zbyt częsta rada, by zapisywać „magiczne” przemieszczenia typu x += 5 na klatkę, prowadzi do gry zakładnika FPS-ów. Taki kod działa jeszcze znośnie, jeśli zablokuje się liczbę klatek na sztywno, natomiast po przeniesieniu na inny sprzęt albo inną platformę efekty bywają kuriozalne – postać nagle porusza się dwa razy szybciej lub dwa razy wolniej, bo jedyną „jednostką czasu” jest niejawne „na klatkę”.

Prosta fizyka 2D z wykorzystaniem stałego kroku

Stały krok logiki szczególnie pomaga przy symulacji grawitacji, skoków, odbić. Zmienny dt powoduje, że równania ruchu z drugą pochodną zaczynają się rozjeżdżać – zwłaszcza, gdy dt chwilowo rośnie. Fixed timestep upraszcza implementację i ułatwia reprodukowalne bugi.

Przykład: skok postaci 2D z grawitacją. W modelu ze stałym krokiem wystarczy klasyczne równanie ruchu:

class PhysicsBody {
    double x, y;
    double vx, vy;

    double gravity = 1200.0; // piksele / s^2

    boolean onGround;

    void jump() {
        if (onGround) {
            vy = -500.0; // ujemny, bo y rośnie w dół
            onGround = false;
        }
    }

    void update(double dt) {
        vy += gravity * dt;
        y  += vy * dt;

        // prosta detekcja "podłogi"
        if (y >= groundY) {
            y = groundY;
            vy = 0.0;
            onGround = true;
        }
    }
}

Przy stałym dt = 1/60 wyżej wymienione równania zachowują się przewidywalnie. Wykres pozycji i prędkości nie zależy od tego, czy gra urządza drobne mikroprzycięcia. Przy zmiennym dt i pojedynczym wyskoku do 0.1 s postać nagle „spadnie więcej”, niż powinna, bo równanie vy += gravity * dt dostało kilkukrotnie większy krok.

Jedna z „popularnych sztuczek” polega na jednoczesnym używaniu zmiennego dt w całej grze i stałego kroku tylko dla fizyki. Działa to zaskakująco dobrze, jeżeli reszta logiki (AI, UI, animacje) toleruje drobne odchyłki w czasie. Wówczas:

  • gra zbiera rzeczywisty czas klatki jako frameTime,
  • fizyka zużywa go porcjami o stałej wielkości,
  • pozostałe systemy dostają prawdziwe dt.

To podejście bywa wygodnym etapem pośrednim – nie trzeba od razu przebudowywać całej architektury pod sztywny krok, a newralgiczna część gry (skoki, kolizje) zaczyna działać stabilnie.

Odróżnianie czasu gry od czasu świata zewnętrznego

Duża część projektów na początku miesza dwa porządki: „czas gry” i „czas ścienny”. Wszystko opiera się na tym samym System.nanoTime(), więc do głowy nie przychodzi myśl, że to tak naprawdę dwie zupełnie różne skale.

Praktyczniejsze podejście:

  • czas świata zewnętrznego – odczyty z System.nanoTime(), logi, profilowanie, komunikacja z systemem operacyjnym,
  • czas gry – suma kroków logiki, którą pętla gry „wymyśla” niezależnie od faktycznych opóźnień,
  • czas lokalny systemów – np. zegar animacji UI, który może się zatrzymać na pauzie, gdy świat gry jest zamrożony.

Dzięki temu proste funkcje typu „pauza” albo „slow motion” nie stają się nagle problemem filozoficznym. Wystarczy:

if (!paused) {
    accumulator += frameTime;
}

i gra przestaje „wydawać” czas gry podczas pauzy, mimo że realny zegar tyka. Podobnie „bullet time” sprowadza się do skalowania czasu gry:

double timeScale = 0.3; // 30% normalnej prędkości

accumulator += frameTime * timeScale;

Tego typu efekty bywają praktycznie niewykonalne przy kodzie, który wszędzie wstrzykuje „prawdziwe” dt prosto z zegara systemowego, bez żadnej separacji.

Pętla gry w jednym wątku vs rozdzielenie renderowania i logiki

Przy grach 2D w Javie naturalnym odruchem jest trzymanie wszystkiego w jednym wątku: logika, renderowanie, obsługa wejścia. Jest to sensowne w momencie, gdy:

  • renderowanie odbywa się w Swingu/JavaFX i nie masz pełnej kontroli nad kontekstem graficznym,
  • gra docelowo i tak będzie celować w umiarkowane FPS (np. 60),
  • czas wejścia (klawiatura, mysz) nie musi być superprecyzyjny,
  • nie ma planów na agresywne optymalizacje GPU ani skomplikowane streamingi zasobów.

Dopiero gdy zaczyna brakować oddechu – np. logika kolizji robi się ciężka, a jednocześnie chciałoby się wykorzystać monitor 144 Hz – sensownie jest rozważyć rozdzielenie wątków. Klasyczny wariant: wątek logiki z pętlą fixed timestep i osobny wątek renderujący, który co klatkę tylko czyta przygotowany stan i rysuje (najczęściej z interpolacją). Komunikacja między nimi opiera się na prostych, nieblokujących strukturach (np. podwójny bufor stanu świata), a nie na gęstej sieci locków.

Popularna rada „wrzuć logikę do jednego wątku, render do drugiego, będzie szybciej” przestaje działać, gdy kod nie jest do tego przygotowany. Jeśli każdy obiekt sam w sobie nie jest thread-safe, a dane gry latają po referencjach bez żadnej dyscypliny, to przeniesienie części pętli do nowego wątku kończy się losowymi artefaktami i trudnymi do złapania błędami wyścigów. Zanim zacznie się wyciągać wielowątkowość jako „optymalizację”, lepiej doprowadzić do ładu samą strukturę danych i model czasu; często to już samo z siebie rozwiązuje większość problemów z płynnością.

Alternatywą, która dobrze się skaluje w grach 2D, bywa podejście „prawie jednowątkowe”: cała logika i główna pętla działają w jednym wątku, ale ciężkie, lokalne obliczenia (np. generowanie ścieżek, kompresja assetów, wczytywanie map) zlecane są do puli wątków pomocniczych. Świat gry widzi wtedy wyniki asynchronicznych zadań jako gotowe „snapshoty”, a sama pętla pozostaje przewidywalna. Do momentu, gdy nie potrzebujesz renderingu niezależnie od logiki, takie rozwiązanie zwykle wystarcza.

Przy rozdzielaniu wątków powraca też kwestia zegarów. Wątek renderujący najczęściej żyje blisko „czasu ściennego” – synchronizuje się z vsync, z oknem, z biblioteką graficzną – natomiast wątek logiki podąża za swoim, wirtualnym czasem gry. Synchronizacja między nimi polega raczej na publikowaniu kolejnych stanów świata wraz z ich stemplami czasu gry niż na wymuszaniu ścisłego lockstepu z każdą klatką. Prosty kontrakt „renderer czyta tylko, logika tylko pisze” plus interpolacja po stronie renderera wystarcza, żeby gra zachowywała się stabilnie nawet przy chwilowych dropach FPS.

Jeśli model czasowy jest spójny – prędkości wyrażone „na sekundę”, fizyka liczy się w stałych krokach, a czas gry jest odklejony od zegara systemowego – wtedy wybór między jedną pętlą w jednym wątku a bardziej złożonym podziałem staje się decyzją techniczną, a nie desperacką reakcją na zacinającą się animację.

Ruch zależny od FPS a ruch zależny od czasu gry

Często pojawia się pokusa, by „uprościć sobie życie” i oprzeć ruch postaci bezpośrednio na liczbie klatek. Wzór typu x += speedPerFrame wydaje się niegroźny, dopóki animacja jest przywiązana do jednego monitora i jednej konfiguracji sprzętowej. Problem zaczyna się przy pierwszej wymianie komputera, zmianie rozdzielczości albo pojawieniu się opcji „odblokuj FPS”.

Typowy scenariusz: na laptopie gra „chodzi w 60 FPS”, na desktopie z szybszą kartą – w ~200 FPS. Jeżeli ruch jest liczony „na klatkę”, to:

  • na słabszym sprzęcie postać będzie poruszać się wolniej (mniej aktualizacji na sekundę),
  • na mocniejszym – szybciej i nienaturalnie „nerwowo”.

Remedium jest banalne w teorii: całą grę opierać na prędkościach wyrażonych „na sekundę” i przejść na x += speed * dt. W praktyce często kończy się to hybrydą: większość systemów dostaje poprawne dt, ale gdzieś głęboko w kodzie pozostają stare konstrukcje += 3 „na klatkę”, bo „przecież działało”. Takie „wyspy FPS-owego myślenia” najłatwiej wyłapać, gdy:

  • do debug menu dodasz możliwość dynamicznej zmiany limitu FPS,
  • przyspieszysz lub spowolnisz timeScale i sprawdzisz, które elementy gry zachowują się inaczej niż reszta.

Jeżeli po przełączeniu z 60 na 144 FPS tylko niektóre obiekty zaczynają „latac jak szalone”, to właśnie tam w kodzie siedzi resztkowy ruch „per frame”. Zamiast patrzeć na stacktrace, wygodniej skorzystać z prostych asercji – np. w trybie deweloperskim logika może zgłaszać ostrzeżenie, gdy dt wykracza poza rozsądny zakres, a jakiś system jednak wciąż zakłada, że „dt ≈ 1/60”.

Animacje sprite’ów zsynchronizowane z czasem gry

Płynny ruch postaci to nie tylko pozycja, ale też animacja sprite’a. Klasyczna rada „przerzucaj klatkę animacji co N milisekund” bywa myląca, jeśli miesza czas świata zewnętrznego z czasem gry. Najprostsze podejście to traktowanie animacji dokładnie jak każdy inny system zależny od czasu gry:

class Animation {
    Image[] frames;
    double frameDuration; // sekundy na klatkę, np. 0.08
    double time;          // czas lokalny animacji
    int currentFrame;

    void update(double dt) {
        time += dt;
        while (time >= frameDuration) {
            time -= frameDuration;
            currentFrame = (currentFrame + 1) % frames.length;
        }
    }

    Image getCurrentFrame() {
        return frames[currentFrame];
    }
}

Takie podejście ma kilka zalet przy grach 2D w Javie:

  • na pauzie wystarczy zatrzymać dt dla animacji,
  • przy „slow motion” animacje zwalniają razem z ruchem postaci,
  • ignorujesz wahania FPS – animacja „dogania” czas gry niezależnie od tego, czy klatka trwała 8 czy 20 ms.

Mniej oczywisty problem pojawia się przy nagłych skokach dt (np. pierwszy frame po GC). Jeżeli animacja przeskoczy kilka klatek naraz, może wyglądać to jak drobne „teleporty” ruchu. Gdy ma to znaczenie (np. przy bardzo szybkich atakach), można ograniczyć maksymalną liczbę kroków animacji na jedną aktualizację:

void update(double dt) {
    time += Math.min(dt, 0.05); // nie więcej niż 50 ms „na raz”
    while (time >= frameDuration) {
        time -= frameDuration;
        currentFrame = (currentFrame + 1) % frames.length;
    }
}

To nie rozwiązuje przyczyny (dropy FPS), ale chroni czytelność ruchu przed najgorszymi pikami czasu. W praktyce wystarcza, żeby sekwencje ciosów albo animacje trafień pozostały zrozumiałe wizualnie.

Interpolacja pozycji przy rozdzielonej logice i renderowaniu

Przy pętli ze stałym krokiem logiki i „luźnym” renderowaniu pojawia się klasyczne pojęcie interpolacji. Logika liczy stan np. 60 razy na sekundę, a renderer rysuje tyle razy, na ile pozwala vsync. Żeby uniknąć efektu „schodkowej” animacji, renderer nie powinien rysować poz