Odmierzanie czasu pomiędzy operacjami, obciążenie procesora

Odpowiedz Nowy wątek
2019-05-07 12:20
0

Kiedyś już zakładałem podobny temat, ale teraz trochę inaczej podszedłem do problemu i osiągnąłem pewne efekty, tylko zastanawia mnie ich wiarygodność. Naskrobałem sobie coś takiego:

type
  TCounter = class(TThread)
    procedure Execute; override;
  end;

  TPainter = class(TThread)
    procedure Execute; override;
  end;

var
  Counter: TCounter;
  Painter: TPainter;
  FPS: Integer;

implementation

procedure UpdateFPS;
begin
  Form1.Caption:= IntToStr(FPS);
end;

procedure TCounter.Execute;
var
  Elapsed: Int64;
  Watch: TStopWatch;
begin
  FreeOnTerminate:= True;
  Watch:= TStopWatch.StartNew;
  while not (Terminated = True) and not (Application.Terminated = True) do
    begin
      Elapsed:= Watch.ElapsedMilliseconds;
      if Elapsed >= 1000 then
        begin
          Synchronize(UpdateFPS);
          Elapsed:= 0;
          Watch.Reset;
          Watch.Start;
          FPS:= 0;
        end;
      Sleep(1);
    end;
end;

procedure TPainter.Execute;
begin
  while not (Terminated = True) and not (Application.Terminated = True) do
    begin
      Synchronize(Draw); //Procedurka służąca do generowania obrazka i rysowania go na ekranie przy pomocy TPaintBox
      Inc(FPS, 1);
      Sleep(1);
    end;
end;

procedure TForm1.FormCreate(Sender: TObject);
begin
  Counter.Resume;
  Painter.Resume;
end;

initialization
  Counter:= TCounter.Create(True);
  Painter:= TPainter.Create(True);

finalization
  Counter.Terminate;
  Painter.Terminate;

Od razu zaznaczam: Tak, wiem, nie powinno się używać Suspend i Resume (bo są "przestarzałe"), tylko TMutex i TEvent, ale to tak na szybko, żeby sobie nie komplikować :).

No a właściwie pytanie jest takie: Na ile to co skleciłem jest wiarygodnym miernikiem szybkości? Jestem na etapie testowania różnych algorytmów rasteryzacji i teksturowania, a metoda badawcza "na oko" nie jest wystarczająca, bo muszę mieć twarde dane i wiedzieć, co działa szybciej, a co wolniej.

Druga kwestia: Czym zastąpić Sleep? Znajduje się on w obu wątkach tylko dlatego, że pętle bez niego niemalże mordują procesor. Sam TCounter (którego jedynym zadaniem jest odświeżanie licznika na belce) bez Sleep potrafi wytwarzać nawet 10% obciążenia CPU, a TPainter nawet ponad 20%. Sleep z tego co wiem, nie jest precyzyjne i nie zawsze trwa tyle samo. W przypadku licznika nie stanowi to wielkiego problemu, w końcu nie jest aż tak istotne, czy jego odświeżenie nastąpi raz na 1000 czy tam 1020 milisekund (gołym okiem to nawet niezauważalne), przy rysowaniu "klatek" to już jednak ma znaczenie (i np. bez Sleep(1) uzyskuję 280-290 klatek, z nim jakieś 195-200). Poza tym nawet gdy używam Sleep(1), obciążenie CPU jest dosyć duże, bo wynosi ok. 10%, a CPU mam naprawdę niezły, tj. Intel i7 7700k.

Trzecia sprawa (trochę uboczna): Orientuje się ktoś, czy Direct2D posiada jakieś wbudowane funkcje do mierzenia szybkości renderowania (ilość klatek na sekundę)? Na logikę wydaje się, że tak (bo przecież jest to - nieco uboższy ale jednak - krewniak Direct3D), ale przeszukując neta, na nic takiego nie trafiłem, ani w Delphi, ani w C++.

edytowany 2x, ostatnio: furious programming, 2019-05-07 17:59

Pozostało 580 znaków

2019-05-09 17:43
0

Póki co - jak pisałem - jakoś działa. Synchronizuje samą procedurkę wypluwającą Bitmapę na PaintBox, reszta jest obsługiwana przez poboczny wątek, tyle że kod nie znajduje się w ciele procedury Execute, a został spięty w odrębnej procedurze (Action) i jest przez Execute jedynie wywoływany (nie wiem dlaczego to robi różnicę, ale robi). Nie jestem do końca pewien, czy to faktycznie działa jak powinno, bo nie zauważyłem absolutnie żadnego wpływu na wydajność (a chyba jakiś być powinien?). No ale ok, może faktycznie masz rację i póki co powinienem się zająć czymś innym i wrócić do tej kwestii później. To trochę ryzykowne, bo jeżeli na koniec się okaże, że jednak nie jestem w stanie tego zoptymalizować i uzyskać właściwej wydajności, to cały mój wysiłek pójdzie na marne (bo gra w 1 klatkach na sekundę to słaby pomysł). Niemniej bez licznika i tak nigdzie nie zajdę.

Przeanalizowałem twój kod i mam pewne wątpliwości. Sam początkowo myślałem nad czymś takim, ale pomysł odrzuciłem. To znaczy chciałem pobierać czas przed narysowaniem klatki i po narysowaniu klatki, obliczać różnicę i na tej podstawie odliczać FPS. Niby proste i logiczne, ale u mnie rozstrzał czasu rysowania klatek (mierzony taką metodą) jest zaskakująco wysoki i skacze od 7 do nawet 18 milisekund. 7ms na klatkę daje ok 142 FPS, ale już 18ms daje ledwie 55 FPS, więc trzeba by to chyba jakoś uśrednić, np. pobierać czas rysowania pierwszych 10 klatek i dopiero wtedy obliczać średni FPS na ich podstawie. Ja wybrałem metodę, w której po każdej narysowanej klatce następuje zwiększenie licznika narysowanych klatek, a ten jest co 1000 milisekund wrzucany na ekran i resetowany (tak jak w moim demku na pierwszej stronie). Tylko no właśnie nie jestem pewien, czy to rozbicie na wiele wątków nie zakrzywia mi tu wyniku... Najprościej byłoby pozbyć się wielowątkowości, ale mam świadomość, że na 100% i tak jej nie uniknę, głównie z uwagi na zżerający sporo mocy obliczeniowej raster (a obok niego bufor głębokości i interpolacja różnych wielkości, wszystko liczone per piksel). Po napisaniu jakiegoś w miarę sensownego mechanizmu licznika klatek, planuję się pochylić właśnie nad tym problemem, a w zamyśle chcę, by każdy sześcian (składający się z 12 trójkątów, z czego na ekranie widocznych jest nie więcej niż 6 naraz), był rasteryzowany przez 12 wątków równocześnie (po 2 na trójkąt, żeby móc obrabiać piksele z dwóch kierunków, jak zresztą radzi autor algorytmu, który zastosowałem u siebie).

Zainteresowała mnie też w twoim kodzie jedna rzecz, mianowicie oczekiwanie na klatkę. Jak coś takiego zrealizować? No ba załóżmy, że chcę utrzymać płynność wyświetlania klatek, powiedzmy 60 na sekundę. To oznacza, że na narysowanie jednej klatki mam maksymalnie 16,6 milisekund. Załóżmy, że narysowałem sobie klatkę, zajęło mi to 8 milisekund (czyli do kolejnej musi upłynąć jeszcze drugie tyle). To oznacza, że nie mogę tak po prostu przeskoczyć do rysowania kolejnej klatki, tylko muszę odczekać (bo jak zacznę zbyt wcześnie, to stan klatki będzie opóźniony w stosunku do tego, co powinno być widoczne na ekranie w chwili wyświetlania). Pytanie jak długo mam czekać? Nie wiem przecież, ile czasu zajmie rysowanie kolejnej klatki, może znowu 8ms, a może tym razem np. 17? W tym drugim wypadku (żeby nie było opóźnień), taką klatkę w ogóle powinno się pominąć (frame skipping) i od razu wyświetlić kolejną, ale do czegoś takiego musiałbym chyba napisać buffor dwuklatkowy, albo robić to tak - ponoć też jest taka metoda - że jak nie zdążę z następną klatką na czas, to dla zachowania płynności, powinienem skopiować poprzednią i przystąpić do rysowania kolejnej (z pominięciem tej opóźnionej)... To dosyć złożone i nie do końca wiem, jak się za to zabrać.

edytowany 1x, ostatnio: Crow, 2019-05-09 17:54

Pozostało 580 znaków

2019-05-09 19:12
0

@Crow: jeśli Ci śmiga to co masz to raczej problem został rozwiązany. Nie będę też komentował tego co próbowałeś i co odrzuciłeś, bo nie widziałem wszystkich testowanych przez Ciebie kodów. Przejdę od razu do rzeczy.


Crow napisał(a):

Zainteresowała mnie też w twoim kodzie jedna rzecz, mianowicie oczekiwanie na klatkę. Jak coś takiego zrealizować?

Wszystko znajdziesz w module, którego kod Ci podałem – metoda WaitForNMI służy właśnie do oczekiwania na kolejną klatkę, tak aby gra wykonywała stałe 60fps, generując kolejne klatki równo co ~16,7ms, wspierając dynamicznie zmieniające się obciążenie.

Załóżmy, że narysowałem sobie klatkę, zajęło mi to 8 milisekund (czyli do kolejnej musi upłynąć jeszcze drugie tyle).

Żeby wiedzieć ile czasu trwało renderowanie klatki, należy pobrać czas dwa razy – przed renderowaniem i po nim – a następnie obliczyć różnicę. Nie możesz tutaj bazować na czasie systemowym, bo częstotliwość jego odświeżania wynosi na oko 64Hz, czyli skokami co mniej więcej 16ms. Dlatego potrzebne są funkcje QPF i QPC, aby móc obliczyć dokładny czas w jakim przetwarzano klatkę.

To oznacza, że nie mogę tak po prostu przeskoczyć do rysowania kolejnej klatki, tylko muszę odczekać […]

Tak, i do tego celu możesz użyć Sleep. Ale żeby go użyć, musisz wiedzieć ile zajęło generowanie klatki i ile milisekund pozostało do odczekania. To są proste obliczenia – podałem je niżej.

Pytanie jak długo mam czekać? Nie wiem przecież, ile czasu zajmie rysowanie kolejnej klatki, może znowu 8ms, a może tym razem np. 17?

To trzeba obliczyć dynamicznie. Dzięki temu bez względu na to jak długo trwa generowanie klatki, za każdym razem dostaniesz odpowiednią liczbę milisekund do odczekania. Im dłużej trwało generowanie klatki, tym mniej czasu trzeba odczekać.

W tym drugim wypadku (żeby nie było opóźnień), taką klatkę w ogóle powinno się pominąć (frame skipping) […]

Wszystko zależy od efektu jaki Cię interesuje. Mój platformer jest prywitywny z założenia i wszelkie lagi mają być widoczne, jeśli takowe wystąpią. Każda klatka jest renderowana w oknie – nieważne jak długo była generowana. Natomiast jeśli generowanie trwało za długo (dłużej niż ~16.7ms) to kolejną klatkę generuje się od razu – bez oczekiwania.

To dosyć złożone i nie do końca wiem, jak się za to zabrać.

To dość proste – trzeba tylko poczekać aż trybiki zaskoczą o co w tym wszystkim chodzi. :]


Wyjaśnię Ci dokładnie jak to wygląda w moim platformerze – innej gierki nie robiłem aby się wzorować.

Zacznijmy od tego, że generowanie klatek zajmuje różną ilość czasu – raz jest to np. 4ms (podczas animacji), raz 12ms (podczas właściwej rozgrywki na poziomie). Czas ten jest zależny od skomplikowania renderowania oraz od mocy obliczeniowej procesora (akceleracja sprzętowa nie jest wspierana, więc wszystko przetwarza centralna jednostka).

Aby wiedzieć ile czasu trwało generowanie klatki, najpierw należy wiedzieć ile ”ticków” potrafi procesor wykonać w czasie jednej sekundy. Ta wartość jest stała i pobiera się ją za pomocą funkcji QPF. Teraz aby wiedzieć ile ticków zajęło generowanie klatki, należy pobrać stan licznika przed generowaniem i po nim – to robi się za pomocą QPC. Mając dwa stany licznika, należy obliczyć różnicę tych wartości – wynikiem jest liczba ticków wykorzystanych na generowanie klatki.

W tym momencie mamy dwie liczby – liczbę ticków wykonywanych w czasie jednej sekundy oraz liczbę ticków wykorzystanych na generowanie bieżącej klatki. Aby wiedzieć ile ticków przeznaczonych jest na wygenerowanie jednej klatki, należy wartość pobraną za pomocą QPF podzielić przez framerate.

Wiemy już ile ticków przeznaczonych jest dla jednej klatki, wiemy też ile ticków zajęło generowanie – teraz pozostało obliczyć jaki procent ticków został wykorzystany (procent obciążenia), a ile nie (czas do odczekania). To zwykłe mnożenie na krzyż z jedną niewiadomą – proste do wykonania.

Skoro już wiemy jaki procent ticków pozostał do odczekania, należy go przeliczyć na milisekundy. Skąd je wziąć? To proste – ticki przeliczyliśmy na procentaż i możemy go wykorzystać do obliczeń. Wiemy też ile milisekund przeznaczonych jest dla jednej klatki – to liczba milisekund w jednej sekundzie (czyli 1000) podzielona przez framerate (czyli przez 60). Zmiennoprzecinkowy wynik takiego dzielenia to 16.666(6) i jego mnożymy przez procentaż. Wynikiem jest liczba milisekund do odczekania – w postaci floata, dlatego trzeba go zaokrąglić.

No, to by było na tyle. Teraz czas na pseudokod.


Najpierw pobierzmy liczbę ticków wykonywanych w czasie jednej sekundy i policzmy ile ich przypada na generowanie jednej klatki:

var
  TicksPerFrame: Int64;

{..}

QueryPerformanceFrequency(TicksPerFrame);
TicksPerFrame := TicksPerFrame div 60{fps};

W głównej pętli gry trzeba pobrać stan licznika przed i po generowaniu:

var
  TicksBegin, TicksEnd, TicksUsed: Int64;

{..}

repeat
  QueryPerformanceCounter(TicksBegin);
  // generowanie klatki
  QueryPerformanceCounter(TicksEnd);

  TicksUsed := TicksEnd - TicksBegin;
until Terminated;

Zmienna TicksUsed zawiera liczbę wykorzystanych ticków na generowanie klatki. Teraz musimy policzyć jaki procent ticków pozostał niewykorzystany:

var
  PercentUnused: Single;

{..}

PercentUnused := 1 - TicksUsed / TicksPerFrame;

W zmiennej PercentUnused mamy procentaż w postaci liczby zmiennoprzecinkowej. Teraz ten procentaż należy wykorzystać do obliczenia milisekund, które gra musi odczekać do następnej klatki. Tę liczbę milisekund należy wykorzystać w procedurze Sleep:

var
  WaitTime: Integer;

{..}

WaitTime := Max(Trunc(1000{ms} / 60{fps} * PercentUnused), 0);
Sleep(WaitTime);

Ten sposób pozwoli utrzymać średnio 60fps bez względu na obciążenie. A jeśli to osiągnie 100% lub więcej to odczuwalne będą lagi. Lagi te nie wpłyną w żaden sposób na obliczenia, bo jedyne co spowodują to opóźnienie generowania kolejnej klatki.


Ten sposób wykorzystuję w swojej grze (jeszcze ”w trunku”) i z testów wynika, że działa dość stabilnie. Jeśli nie chcesz używać procedury Sleep, która ma niestety ograniczenia związane z niedokładnością pracy, to zawsze możesz bazować wyłącznie na licznikach ticków i zżerać moc CPU. To da Ci super stabilny klatkaż, jednak kosztem dużego zużycia mocy procesora.

Jeśli chcesz to mogę wrzucić przykład implementacji klasy takiego zegara – będzie to nieco zmodyfikowana wersja klasy zegara z mojego platformera.


edytowany 8x, ostatnio: furious programming, 2019-05-09 23:24

Pozostało 580 znaków

2019-05-09 19:44
2

W załączniku projekt prezentujący ciekawy efekt i do tego liczy FPS-y ;)
ps. projekt nie jest mojego autorstwa.

Pozostało 580 znaków

2019-05-09 23:27
0

@PrzemysławWiśniewski: liczenie framerate'u jest banalnie proste, bo ogranicza się do inkrementowania licznika na podstawie numeru sekundy z systemowego zegara. Większym problemem jest stworzenie mechanizmu, który pozwoli utrzymać stabilny klatkaż bez względu na obciążenie i jednocześnie liczący klatki.

Ale to też da się załatwić kilkoma liniami kodu, więc… ;)


Edit: do tego co podałem wcześniej trzeba będzie dodać jeszcze trochę kodu, aby ustabilizować klatkaż, jeśli odstępy pomiędzy klatkami mają być realizowane za pomocą procedury Sleep.


edytowany 1x, ostatnio: furious programming, 2019-05-10 00:46

Pozostało 580 znaków

2019-05-11 00:13
0

Napisałem coś takiego:

//MaxFrameTicks, CPUFrequency, TickStart, TickEnd, FrameTickTime to Int64

//W konstruktorze wątku:
QueryPerformanceFrequency(CPUFrequency); //Pobiera częstotliwość taktowania zegara
MaxFrameTicks:= Floor(CPUFrequency / 60); //Oblicza ile tickow maksymalnie powinno zająć wygenerowanie i wyświetlanie jedniej klatki

//Główna procedura
procedure TGameLoop.Execute;
begin
  FreeOnTerminate:= True;
  while not (Terminated) or not (Application.Terminated) do
    begin
      QueryPerformanceCounter(TickStart); //Pobiera "czas" przed rozpoczęciem generowania klatki
      Action; //Tutaj wykonywane są wszystkie przekształcenia, renderowanie, wyświetlanie na ekranie itd.
      QueryPerformanceCounter(TickEnd); //Pobiera "czas" po zakończeniu generowania klatki
      FrameTickTime:= TickEnd - TickStart; //Oblicza rzeczywisty czas generowania klatki
      if FrameTickTime < MaxFrameTicks then Sleep(Round(((MaxFrameTicks - FrameTickTime) * 1000) / CPUFrequency)); //Jeżeli trzeba, "odsypia" przed rozpoczęciem generowania kolejnej klatki.
    end;
end;

Działa, ale... Teoretycznie czas generowania 1 klatki nigdy nie przekracza czasu maksymalnego (czyli 16ms) i LOG mi pokazuje, że proces ten trwa to od 5 do 12ms. To oznacza, że żadna klatka nie jest opóźniona i wszystkie powstają na czas (a nawet przed czasem i muszą czekać na swoją kolejkę). Niemniej mam wrażenie (a może to już mózg mi świruje od wpatrywania się w ekran), że czasem animacja nie jest odpowiednio płynna i przyspiesza lub zwalnia. Czy to możliwe, że problem stanowi tutaj brak precyzji Sleep?

Druga sprawa, poczytałem trochę więcej o wątkach i eksperymentowałem z Synchronize i Queue. Wiem, że oba działają podobnie, z tą różnicą, że Queue nie czeka na wykonanie zakolejkowanej procedury i od razu zwraca kontrolę wątkowi pobocznemu. Gdy robię coś takiego:

Queue(Action)

to całość dostaje z 20-30% więcej FPSów. Jak rozumiem, różnica polega na tym, że przy Queue moja pętla nie czeka na wykonanie podsuniętej wątkowi głównemu procedury Action, tylko zapycha kolejkę do oporu. Skutek jest więc taki (jeżeli dobrze to rozumiem), że wątek główny nie zajmuję się niczym innym, tylko "obrabianiem" tego, co mu podrzucam (bo inaczej niż przy synchronize, inne procesy nie mogą się "dopchać" do kolejki i wcisnąć się pomiędzy kolejne wykonania mojej procedury), stąd przyrost szybkości wyświetlania. Tylko no właśnie, skoro mój wątek poboczny nie zajmuje się niczym innym, niż tylko obróbką mojej procedury, to czy efekt nie powinien być taki sam (to znaczy ilość FPS jak przy użyciu Queue)? Sądziłem, że chodzi może o priorytet i próbowałem dać wątkowi pobocznemu najwyższy, ale to niczego nie zmieniło...

edytowany 1x, ostatnio: Crow, 2019-05-11 00:17
Moment – właśnie działam w tym temacie. :] - furious programming 2019-05-11 00:16

Pozostało 580 znaków

2019-05-11 00:39
0
Crow napisał(a):

Niemniej mam wrażenie (a może to już mózg mi świruje od wpatrywania się w ekran), że czasem animacja nie jest odpowiednio płynna i przyspiesza lub zwalnia. Czy to możliwe, że problem stanowi tutaj brak precyzji Sleep?

Oczywiście, a nawet więcej – na końcowy wynik ma wpływ to której funkcji użyjesz do zaokrąglania wyniku. Co innego uzyskasz za pomocą Round czy Ceil, a co innego za pomocą Trunc czy Floor.

to całość dostaje z 20-30% więcej FPSów. Jak rozumiem, różnica polega na tym, że przy Queue moja pętla nie czeka na wykonanie podsuniętej wątkowi głównemu procedury Action, tylko zapycha kolejkę do oporu.

A masz już zaimplementowany licznik FPSów i widzisz jego wartość na ekranie podczas testowania? Bo jeśli nie, to nie wiadomo czy i ile jest tych klatek oraz nie wiadomo czy gra trzyma się ustalonego limitu, czy jednak Queue powoduje wykonywanie większej liczby klatek niż przyjęta górna granica.


Problem polega na tym, że taki timer nie może wyłącznie bazować na Sleep, bo ta procedura jest cholernie niedokładna, czego efektem jest dynamiczny framerate o wartości dużo niższej (i zawsze niższej) niż limit 60fps – gra będzie chrupać, co będzie widoczne.

Dlatego potrzeba timera hybrydowego – używającego procedury Sleep do oczekiwania przez większą część czasu przerwy pomiędzy klatkami, a pozostałą część po prostu musi jakoś ”zżerać”. To pozwoli oszczędzić moc obliczeniową procesora, jednocześnie utrzymując stabilny klatkaż. Z moich badań wynika, że narzut wynosi około 10-20%, czyli tyle mocy w każdej sekundzie gra pożera – pozostałe 80-90% oszczędza Sleep.

Czyli dla przykładu klatka generowana jest w czasie 10ms, pozostałe 6.7ms musi być odczekane. Procedura Sleep zamraża program na 5ms, pozostałe 1.7ms jest zjadanych. Tak wygląda praca hybrydowego timera.

Obecna metoda WaitForNMI z mojego zegara wygląda teraz tak:

procedure TClock.WaitForNMI();
var
  SleepTime: Single;     // czas oczekiwania na kolejną klatkę (w milisekundach)
  NextFrameTicks: Int64; // stan licznika dla początku kolejnej klatki
  CurrentTicks: Int64;   // stan licznika pobierany na bieżąco
begin
  // obliczenie liczby milisekund do odczekania
  SleepTime := 1000 / FFramerateLimit * (1 - (FFrameTicksEnd - FFrameTicksBegin) / FTicksPerFrame);
  // odjęcie 2ms od wyniku, aby Sleep nie zamrażał gry na więcej niż to konieczne
  SleepTime := Max(SleepTime - 2, 0);

  // zamrożenie pracy programu
  Sleep(Round(SleepTime));

  // obliczenie stanu licznika dla początku kolejnej klatki
  NextFrameTicks := FFrameTicksBegin + FTicksPerFrame;

  // pętla wpieprzająca całą moc procesora, w kółko odczytując stan licznika
  // stanowi precyzyjne wyrównanie jako dopełnienie ułomności procedury Sleep
  repeat
    CurrentTicks := GetHardwareCounterValue();
  until CurrentTicks >= NextFrameTicks;
end;

To rozwiązanie powoduje, że gra przez większość czasu utrzymuje 60fps. Raz na jakiś czas widoczne są wahania i wtedy framerate zmienia się o 1fps, czyli utrzymuje się w przedziale 59-61fps. Większych wahań nie zaobserowowałem w ogóle, czyli to rozwiązanie jest najlepszym co dałem radę uzyskać i jak najbardziej mnie ono zadowala.


Jeśli chcesz, możesz się zapoznać artykułem Results of some quick research on timing in Win32 – sekcja Accurate FPS Limiting / High-precision 'Sleeps' zawiera opis ciekawego sposobu. Jednak sam dokładnie przeportowałem kod z tego punktu i w moim platformerze daje on niestabilne 57fps – nie zadowolił mnie.


edytowany 3x, ostatnio: furious programming, 2019-05-11 00:45

Pozostało 580 znaków

2019-05-11 01:09
0

Jeśli chcesz to skorzystaj z tego co mam. Zawartość sekcji interface i implementation modułu niżej. Powinieneś móc skorzystać z tego kodu bez żadnych przeróbek.

interface

uses
  Windows, SysUtils, DateUtils;

type
  TClock = class(TObject)
  private
    FTicksPerSecond: Int64;
    FTicksPerFrame: Int64;
  private
    FFramerateLimit: Integer;
    FFramerateCurrent: Integer;
    FFrameratePrevious: Integer;
  private
    FFrameIndex: Integer;
    FFrameLoad: Integer;
    FFrameTicksBegin: Int64;
    FFrameTicksEnd: Int64;
  private
    FCurrentSecond: Integer;
  private
    procedure SetFramerateLimit(ALimit: Integer);
  private
    function GetHardwareCounterFrequency(): Int64;
    function GetHardwareCounterValue(): Int64;
  public
    constructor Create();
  public
    procedure UpdateFrameBegin();
    procedure UpdateFrameEnd();
  public
    procedure WaitForNextFrame();
  public
    property FrameIndex: Integer read FFrameIndex;
    property FrameLoad: Integer read FFrameLoad;
  public
    property Framerate: Integer read FFrameratePrevious;
    property FramerateLimit: Integer read FFramerateLimit write SetFramerateLimit;
  end;

implementation

uses
  Math;

constructor TClock.Create();
begin
  FFrameIndex := 0;
  FFrameLoad := 0;

  FFramerateLimit := 60;
  FFramerateCurrent := FFramerateLimit;
  FFrameratePrevious := FFramerateLimit;

  FTicksPerSecond := GetHardwareCounterFrequency();
  FTicksPerFrame := FTicksPerSecond div FFramerateLimit;

  FCurrentSecond := SecondOf(Now());
end;

procedure TClock.SetFramerateLimit(ALimit: Integer);
begin
  FFramerateLimit := Max(Min(ALimit, 60), 1);
  FTicksPerFrame := FTicksPerSecond div FFramerateLimit;
end;

function TClock.GetHardwareCounterFrequency(): Int64;
begin
  Result := 0;
  QueryPerformanceFrequency(Result);
end;

function TClock.GetHardwareCounterValue(): Int64;
begin
  Result := 0;
  QueryPerformanceCounter(Result);
end;

procedure TClock.UpdateFrameBegin();
begin
  FFrameTicksBegin := GetHardwareCounterValue();
end;

procedure TClock.UpdateFrameEnd();
var
  NewSecond: Integer;
begin
  FFrameTicksEnd := GetHardwareCounterValue();
  NewSecond := SecondOf(Now());

  if NewSecond = FCurrentSecond then
    Inc(FFramerateCurrent)
  else
  begin
    FFrameratePrevious := FFramerateCurrent;
    FFramerateCurrent := 1;

    FCurrentSecond := NewSecond;
  end;

  if FFrameIndex mod 12 = 0 then
    FFrameLoad := (FFrameTicksEnd - FFrameTicksBegin) * 100 div FTicksPerFrame;

  Inc(FFrameIndex);
end;

procedure TClock.WaitForNextFrame();
var
  SleepTime: Single;
  NextFrameTicks, CurrentTicks: Int64;
begin
  SleepTime := 1000 / FFramerateLimit * (1 - (FFrameTicksEnd - FFrameTicksBegin) / FTicksPerFrame);
  SleepTime := Max(SleepTime - 2, 0);

  Sleep(Round(SleepTime));

  NextFrameTicks := FFrameTicksBegin + FTicksPerFrame;

  repeat
    CurrentTicks := GetHardwareCounterValue();
  until CurrentTicks >= NextFrameTicks;
end;

end.

Zadeklaruj sobie gdzieś zmienną dla tego timera lub wykorzystaj gołe obliczenia w wątku. Jego użycie jest proste. Zakładając, że w polu wątku o nazwie FClock trzymasz instancję tego zegara, skorzystanie z niego w Twoim kodzie wyglądać będzie w ten sposób:

procedure TGameLoop.Execute();
begin
  FreeOnTerminate:= True;

  while not (Terminated) or not (Application.Terminated) do
  begin
    FClock.UpdateFrameBegin();
    Action();
    FClock.UpdateFrameEnd();
    FClock.WaitForNextFrame();
  end;
end;

Jeśli chcesz wiedzieć jaki jest obecnie framerate, wyświetl na ekranie wartość właściwości Framerate. Jeśli chcesz znać czas generowania klatek w postaci procentowej, wyświetl FrameLoad. Jeśli chcesz znać numer wykonywanej klatki, wyświetl FrameIndex.

Jeśli chcesz sprawdzić działanie różnych mechanizmów gry w zwolnionym tempie, ustaw niższą wartość właściwości FramerateLimit. Ta właściwość przechowuje aktualny framerate z zakresu <1,60> (domyślnie 60), który też możesz sobie wyświetlić na ekranie.

Jak chcesz to skorzystaj z tej klasy, a jak Ci ona nie odpowiada, nie chcesz osobnej klasy timera lub nie są Ci potrzebne wszystkie jego funkcje to skorzystaj z samych obliczeń. Jeśli nie podoba Ci się sposób wykonywania przerw pomiędzy klatkami, to baw się metodą WaitForNextFrame, bo to ona i tylko ona odpowiada za tworzenie przerw.


edytowany 6x, ostatnio: furious programming, 2019-05-11 01:17

Pozostało 580 znaków

2019-05-11 14:42
0

Dobra, skleciłem coś takiego:

//TicksPerFrame - Maksymalny czas generowania i wyświetlania jednej klatki

procedure TGameLoop.Execute;
var
  TickStart, TickEnd, TicksToWait, TicksToWaste: Int64;
  TimeToSleep: Integer;
  TimeToWait: Double;
begin
  FreeOnTerminate:= True;
  while not (Terminated) or not (Application.Terminated) do
    begin
      QueryPerformanceCounter(TickStart);
      Action;
      QueryPerformanceCounter(TickEnd);
      FrameTickTime:= TickEnd - TickStart; //Oblicza czas klatki
      TicksToWait:= TicksPerFrame - FrameTickTime; //Oblicza ile musi odczekać do następnej klatki (w tickach)
      if TicksToWait > 0 then
        begin
          TimeToWait:= TicksToMilliseconds(TicksToWait); //Konwertuje długość całkowitego oczekiwania z ticków na milisekundy
          TimeToSleep:= (TicksToWait * 1000) div CPUFrequency; //Oblicza ile pełnych milisekund musi odczekać (tyle będzie spał)
          TicksToWaste:= MillisecondsToTicks(TimeToWait - TimeToSleep); //Oblicza ile dodatkowo niepełnych sekund (w tickach) musi przeczekać (wymarnować w bezczynności) do następnej klatki. Po prostu odejmuje liczbę pełnych sekund, od całkowitego czasu czekania (np. 7.4 - 7 = 0.4) i konwertuje znowu na ticki
          WasteTickTime(TicksToWaste); //Odczekuje tyle ticków, ile mu wyszło w powyższym obliczeniu
          Sleep(TimeToSleep); //Resztę przesypia
        end;
    end;
end;

function TGameLoop.TicksToMilliseconds(pTicks: Int64): Double;
begin
  Result:= (pTicks * 1000) / CPUFrequency;
end;

function TGameLoop.MillisecondsToTicks(pMilliseconds: Double): Int64;
begin
  Result:= Round((pMilliseconds * CPUFrequency) / 1000);
end;

procedure TGameLoop.WasteTickTime(pTicksToWaste: Int64);
var
  TickCount: Int64;
begin
  repeat QueryPerformanceCounter(TickCount) until TickCount >= pTicksToWaste;
end;

Szczerze mówiąc, to sam nie wiem, czy zauważyłem jakąś różnicę. Zechciałbyś przetestować u siebie, gdybym wrzucił gdzieś skompilowany kod?

A przy okazji, czemu w swoim kodzie odejmujesz na sztywno 2 milisekundy od czasu spania, zamiast wyliczyć sobie dokładny czas snu w pełnych milisekundach?

Póki co nie używam bezpośrednio twojego kodu w swojej grze (choć jestem za niego wdzięczny i używam do testowania oraz porównań z tym, co sam piszę!), bo to trochę overkill. Działać będzie na pewno, ale ja muszę dojść małymi kroczkami do tego co, jak i dlaczego robi, samo przeklejenie kodu nie będzie zbyt pożyteczne z punktu widzenia edukacyjnego :).

Porównałem też wynik działania mojego kodu z twoim i oba wydają mi się identyczne, tak wizualnie, jak i liczbowo (oba dają FPS w zakresie 59-61).

edytowany 19x, ostatnio: Crow, 2019-05-11 16:49

Pozostało 580 znaków

2019-05-11 20:22
1
Crow napisał(a):

Szczerze mówiąc, to sam nie wiem, czy zauważyłem jakąś różnicę. Zechciałbyś przetestować u siebie, gdybym wrzucił gdzieś skompilowany kod?

Podaj plik wykonywalny to przetestuję. Delphi nie mam i nie używam, więc źródła nie będą konieczne. Ale już teraz mogę powiedzieć, że nie będzie stabilnego klatkażu z kilku powodów – o tym niżej.

A przy okazji, czemu w swoim kodzie odejmujesz na sztywno 2 milisekundy od czasu spania, zamiast wyliczyć sobie dokładny czas snu w pełnych milisekundach?

Pisałem Ci już o tym – Sleep jest niedokładny. Ty sobie policzysz, że gra ma czekać 10ms, a ten poczeka 11ms. I co wtedy? Ta procedura notorycznie przesypia więcej o 1ms niż to wynika z wyliczonej wartości, tym częściej, im niższa liczba milisekund. Przy czym robi to zawsze, jesli ma przespać jedną lub dwie milisekundy. Tego błędu nigdzie nie uwzględniasz, nie zapamiętujesz go, nie dodajesz do czasu który trzeba zjeść. Dlatego należy mu zadać mniej o jedną milisekundę niż to wynika z obliczeń.

Druga sprawa to zaokrąglanie zmiennoprzecinkowego wyniku, aby można go było użyć w procedurze Sleep. Do tego używasz funkcji Round, która może zaokrąglić w dół (i gra poczeka za mało) lub co gorsza w górę (wtedy gra poczeka za długo). Dlatego należy procedurze Sleep zadać mniej o kolejną milisekundę niż to wynika z obliczeń.

Podsumowując – odejmuję dwie milisekundy po to, aby zabezpieczyć się przed niedokładnością Sleep oraz ewentualnym narzutem wynikającym z zaokrąglenia.

Poza tym robisz błąd w jeszcze jednym miejscu – metoda WasteTickTime zjada tyle czasu ile to wynika z obliczeń, a obliczenia nie biorą pod uwagę niedokładności Sleep. Sam podałeś przykład – 7.4ms do odczekania, 7ms odczekuje Sleep a 0.4ms jest zjadane. Tymczasem Sleep odczeka 8ms (zdaży się, bo jest nieprecyzyjny), a metoda WasteTickTime nic o tym nie wie (bo tego nie sprawdza), więc odczeka swoje 0.4ms. No i w ten sposób gra odczeka 8.4ms, o jedną milisekundę za dużo.

Podsumowując – dlatego właśnie w mojej metodzie najpierw wołam Sleep, a w pętli jedzącej CPU nie bazuję na obliczeniach, a porównuję w pętli bieżącą wartość QPC z globalną liczbą ticków, która określa początek kolejnej klatki. Dzięki temu zawsze brane są pod uwagę wahania Sleep połączone z nieznanym kierunkiem zaokrąglenia funkcji Round.

Póki co nie używam bezpośrednio twojego kodu w swojej grze (choć jestem za niego wdzięczny i używam do testowania oraz porównań z tym, co sam piszę!), bo to trochę overkill.

Dlaczego overkill?

Masz w tej klasie wszystko co jest Ci potrzebne – dwie metody pozwalające pobrać stan sprzętowego licznika przed i po generowaniu klatki, obliczanie framerate'u i obciążenia oraz metoda oczekująca na kolejną klatkę. Wszystko w postaci klasy z krótkimi metodami i właściwościami, aby utrzymać ładny i czytelny kod, separując obliczenia od pozostałych mechanizmów gry.

Nie ma powodu, aby unikać podziału kodu na klasy i krótkie metody.

Działać będzie na pewno, ale ja muszę dojść małymi kroczkami do tego co, jak i dlaczego robi, samo przeklejenie kodu nie będzie zbyt pożyteczne z punktu widzenia edukacyjnego :).

Wszystko rozumiem, dlatego jeśli chcesz pisać kod po swojemu (który nie za bardzo mi się podoba – nie lubię mieszać obliczeń z wywołaniami metod, tak jak masz w Execute) to weź pod uwagę wykorzystane wzory i kolejność wykonywania instrukcji.

Porównałem też wynik działania mojego kodu z twoim i oba wydają mi się identyczne, tak wizualnie, jak i liczbowo (oba dają FPS w zakresie 59-61).

Też myślałem że będzie dobrze, bo u mnie klatkaż był stabilny. A potem odpaliłem grę na innym, lepszym laptopie z Win7 i dostałem stabilne 58fps (dla Round) oraz 64fps (dla Trunc) i przestałem chojrakować. :]

Dlatego biorę pod uwagę wszystkie możliwości, czyli różne kierunki zaokrąglenia i niedokładność Sleep, aby bez względu na to na jakim komputerze odpali się grę, ta zawsze precyzyjnie odmierza czas.


edytowany 5x, ostatnio: furious programming, 2019-05-11 20:51

Pozostało 580 znaków

2019-05-11 20:35
1

Dodałem jeszcze małe usprawnienie, dzięki któremu obniżyłem zużycie mocy CPU o 5-15%.

Skoro Round może zaokrąglić w górę i nakazać procedurze Sleep poczekać nieco więcej, to można tę drugą milisekundę odjąć tylko wtedy, gdy ten zaokrągli w górę. W przeciwnym razie – gdy zaokrągli w dół – dodatkowa milisekunda nie jest odejmowana.

SleepTime := 1000 / FFramerateLimit * (1 - (FFrameTicksEnd - FFrameTicksBegin) / FTicksPerFrame) - 1;
SleepTime -= Ord(Round(SleepTime) > SleepTime);
SleepTime := Max(SleepTime, 0);

Sleep(Round(SleepTime));

NextFrameTicks := FFrameTicksBegin + FTicksPerFrame;

repeat
  CurrentTicks := GetHardwareCounterValue();
until CurrentTicks >= NextFrameTicks;

W pierwszej linijce odejmujemy milisekundę, co zabezpiecza przed niedokładnością Sleep. W drugiej linijce odejmujemy drugą milisekundę, jeśli wynik zaokrąglenia jest większy niż bez zaokrąglenia – to skraca czas do zjedzenia w końcowej pętli, oszczędzając moc procesora. W trzeciej linijce przerabiamy wynik na liczbę nieujemną, bo tylko taką obsługuje Sleep – to zabezpiecza przed wywaleniem wyjątku gdy nastąpi lag, czyli gdy czas generowania klatki będzie dłuższy niż czas przydzielony na jej obsługę.


edytowany 7x, ostatnio: furious programming, 2019-05-12 03:57

Pozostało 580 znaków

Odpowiedz
Liczba odpowiedzi na stronę

1 użytkowników online, w tym zalogowanych: 0, gości: 1, botów: 0