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

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++.

1

Ja do mierzenia czasu trwania procesów używam GetTickCount

procedure Tmain.btn1Click(Sender: TObject);
var
  start: Integer;
begin
  start := GetTickCount;
  Sleep(1000);
  ShowMessage(IntToStr(GetTickCount - start));
end;
0

TStopWatch jest bardziej precyzyjny od GetTickCount, ale to nie o to chodzi. Ja się raczej zastanawiam, czy wątki skonstruowane w taki sposób dają miarodajny wynik, czy nie. No i nadal nie wiem, czym mógłbym zastąpić nieprecyzyjny Sleep.

1

a po co Ci drugi wątek? Nie możesz tego policzyć w tym jednym? BTW jeśli całe rysowanie masz w synchronize to równie dobrze możesz zrezygnować z wątku

1

no sleep niczym nie zastąpisz, powinieneś go po prostu wywalić. procesor obciążony na 100%? Logiczne - w tym przypadku program będzie działał z pełną wydajnością; niezależnie jak szybki będzie procesor zawsze będzie obciążenie 100%. Dlaczego gry i inne programy rysujące nie obciążają na 100%? No bo rysowanie to raczej zadanie dla GPU nie CPU i ten drugi tylko wysyła parametry, tekstury i shadery i zleca rysowanie GPU; powinieneś raczej podążać tym tropem - w tym przypadku CPU nie jest obciążony w 100% tylko dlatego że czeka na GPU, w idealnej konfiguracji dla maksymalnego fps i gpu i cpu jest obciążony blisko w 100 procentach

dla mierzenia czasu wywal sleep całkowicie, lub mierz tylko to co się dzieje w Draw i sumuj
w prawdziwej aplikacji dla oszczędności CPU zrób frame limiter i dostosuj wartość sleepa tak żeby uzyskać stałą liczbę fpsów (nie więcej niż 60), możesz wykorzystać sprzężenie zwrotne i dostosować prędkość rysowania do użycia cpu

1
Crow napisał(a):

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ć :).

Mam nadzieję, że Suspend i Resume są oznaczone jako deprecated.


Na ile to co skleciłem jest wiarygodnym miernikiem szybkości?

No niezbyt wiarygodnym. Nie wiem po co Ci do tego wątki, które tylko komplikują całość, nie wiem dlaczego jednostką pomiaru jest framerate, a nie jednostka czasu i nie wiem po co ten Sleep.

Aby mieć najbardziej wiarygodne pomiary, kod należy wykonać w głównym wątku, wielokrotnie, na nieobciążonym pececie, poza debuggerem i innymi narzędziami, które mogą spowalniać wykonanie kodu. Dodatkowo można ustawić wysoki priorytet dla procesu aplikacji testującej. Odpala się pętlę i w niej mierzy się czas wykonania właściwego algorytmu. Czas się mierzy, bo znając czas możesz sobie sam policzyć framerate.

Jeśli o odmierzanie czasu chodzi, to stosuję dwie techniki, obie wykorzystują funkcje QPF i QPC.

Pierwsza technika dotyczy pomiaru szybkości działania danego algorytmu. Oczywiście wołam go setki czy tysiące razy w pętli, pobierając za pomocą funkcji QPC stan licznika przed i po wywołaniu właściwej funkcji. Następnie pobieram rozdzielczość countera za pomocą QPF i przeliczam wynik na jednostkę czasu.

Druga technika dotyczy pomiaru potrzebnego podczas optymalizowania działania danego algorytmu – aby wiedzieć czy poprawiona wersja kodu daje lepsze rezultaty i jeśli tak, to o ile (w procentach). Wtedy przed wywołaniem właściwego kodu i po nim pobieram czas za pomocą QPC i sprawdzam jaka jest różnica, przeliczając ją na procenty.

Powyższe dotyczy testowania różnej maści algorytmów i przedstawiania wyników pomiarów w konsoli. Jeśli test ma dotyczyć generowania grafiki i jej renderowania na ekranie, to dorzucam wywołanie metody odmalowującej komponent/okno (w zależności od przeznaczenia algorytmu) oraz Application.ProcessMessages tuż po wywołaniu właściwej funkcji, ale przed pobraniem stanu licznika, tak aby wymusić przemalowanie płótna i oczyścić kolejkę komunikatów. Bez tego okno nie zostanie fizycznie przemalowane, więc wyniki pomiarów będą nieprawidłowe.


Przykładem jest mój Deep Platformer. Działa on w ten sposób, że generuje klatkę, przemalowuje okno, oczekuje na kolejną klatkę, następnie znów generuje klatkę i tak w kółko (działa bardzo podobnie jak NES), utrzymując 60fps. Na ekranie widoczny jest licznik framerate'u oraz procentowy licznik obciążenia procesora.

Jeśli optymalizuję kod to sprawdzam czy licznik procentowego obciążenia pokazuje niższą wartość niż wcześniej – podczas gry jest to około 41%. Jeśli chcę wiedzieć ile klatek da radę gra wyciągnąć to remuję wywołanie metody oczekującej na kolejną klatkę – otrzymuję 147fps. Jeśli chcę wiedzieć ile klatek gra wyciąga bez odmalowywania okna to remuję wywołanie metody odmalowującej okno – otrzymuję 223fps. A jeśli chcę sprawdzić ile klatek gra wyciągnie bez generowania bitmap klatek i odmalowywania okna (sama logika) to dodatkowo remuję wywołanie metody odpowiedzialnej za renderowanie klatki – otrzymuję około 18.095fps. Wyniki wyrzucam na ekran konsoli.

Proste w użyciu i daje dużo możliwości.


Druga kwestia: Czym zastąpić Sleep?

Zastąp go pustą linijką – nie jest do niczego potrzebny. A nawet inaczej, jest absolutnie zbędny i służy wyłącznie do przekłamywania wyników. Twój obecny program przedstawia wynik w postaci framerate'u, którego wartość jest sztucznie zaniżana przez tę procedurę.


Znajduje się on w obu wątkach tylko dlatego, że pętle bez niego niemalże mordują procesor.

Nie wiem czy wiesz, ale pomiary będą wiarygodne tylko jeśli aplikacja testująca będzie zjadać całą dostępną moc procesora (a właściwie: jednego jego jądra). Jeśli nie będzie tego robić, to wynikowy framerate będzie przekłamany – tym bardziej, im niższe zużycie CPU podczas testowania.

Testowanie szybkości działania algorytmu stricte wielowątkowego to inna para kaloszy.

1

Dzięki za odpowiedzi. Postaram się wyjaśnić trochę więcej. Otóż piszę sobie hobbystycznie klona trójwymiarowego Tetrisa, gdzie klocki układa się w formie wieży na planszy o powierzchni 3x3. To jest androidowy pierwowzór: W mojej wersji wieża może mieć maksymalnie 20 pięter, z czego każde piętro może składać się z 8 sześcianów (bo 9 oznacza wykasowanie piętra), a każdy sześcian to oczywiście 8 wierzchołków. Razem z podstawką daje to maksymalnie ok. 1300 wierzchołków, które mogą jednocześnie pojawić się na ekranie. To na tyle mało, że wszystkie przekształcenia geometryczne (mnożenia macierzowe), mogę spokojnie robić na CPU i uzyskać przy tym zadowalającą płynność (mój procek zaczyna odczuwać cokolwiek dopiero w okolicy 8-10 tysięcy wierzchołków). Gorzej jest z renderowaniem samych sześcianów, zwłaszcza biorąc pod uwagę, że swoją wersję chcę jeszcze oteksturować. Ogólnie mam zamiar skorzystać z Direct2D (Directem 3D nie chcę się bawić, zwłaszcza że Delphi chyba nawet nie ma sensownego wrappera). Ten jednak - z tego co mi wiadomo - wbudowanego rastra nie ma (poprawcie mnie, jeżeli się mylę). To znaczy może pokryć wielokąt bitmapą (tak jak i GDI czy GDI Plus), ale nie z zastosowaniem perspektywy, niezbędnej przy rzutowaniu obiektu 3D na płaszczyznę. No więc wydaje mi się, że muszę mieć własny raster, zapisywać wynik jego pracy do buffora (tak obecnie robię), a dopiero potem przerzucać go do Direct2D, by ten wyświetlił mi go na ekranie. Napisałem więc ten raster (skorzystałem z algorytmu Juana Pinedy: "A Parallel Algorithm for Polygon Rasterization" i koordynatów barycentrycznych) i nawet jakoś działa:

title

Nie jestem jednak pewien co do jego wydajności, zwłaszcza, gdy na ekranie pojawi się więcej obiektów, stąd muszę mieć jakieś narzędzie porównawcze, które pomoże mi w ewentualnej optymalizacji (a mam jeszcze kilka miejsć, w których mogę uzyskać kilka dodatkowych milisekund, np. poprzez rasteryzację wielowątkową - obecnie używam tylko 1 wątku). Chce więc widzieć co przynosi poprawę, a co nie, bo przecież na oko tego nie zrobię :).

0

Podstawowy ”pomocnik” to zwykły licznik framerate'u – on wszystko Ci powie. No ale tego implementacja zależy od implementacji głównej pętli gry – pisałeś ją sam, czy korzystasz z jakiegoś frameworka do tworzenia gier?

0

Cały silnik piszę od zera i własnymi siłami (nie licząc pomocy z forum, zwłaszcza twojej; jak skończę, nie omieszkam wspomnieć o tym w creditsach ;D), małymi kroczkami starając się wszystko poskładać do kupy :).

0

A w jakiej wersji środowiska programujesz ? Bo rozwiązaniem twojego problemu wydaje się być FireMonkey :)

0
PrzemysławWiśniewski napisał(a):

A w jakiej wersji środowiska programujesz ? Bo rozwiązaniem twojego problemu wydaje się być FireMonkey :)

RAD Studio 10.2 (Tokyo Starter)

0
Crow napisał(a):

Cały silnik piszę od zera i własnymi siłami […]

Jeśli eksperymentujesz czy piszesz głównie dla zabawy to nic w tym złego. :]

Skoro tak, to jak działa główna pętla? Używasz delty w celu wyciągania maksymalnej liczby klatek (ewentualnie z opcjonalnym ograniczeniem do 60fps) czy liczysz klatki, jak w grach na stare konsole?

1

Takie małe wtrącenie - mam nadzieję, że jak uda Ci się doprowadzić projekt do końca to się podzielisz z nami wynikiem swojej pracy :)

0
furious programming napisał(a):
Crow napisał(a):

Cały silnik piszę od zera i własnymi siłami […]

Jeśli eksperymentujesz czy piszesz głównie dla zabawy to nic w tym złego. :]

Skoro tak, to jak działa główna pętla? Używasz delty w celu wyciągania maksymalnej liczby klatek (ewentualnie z opcjonalnym ograniczeniem do 60fps) czy liczysz klatki, jak w grach na stare konsole?

Mój obecny "game loop" jest bardzo prosty (bo sklecony "na kolanie", żeby móc "podziwiać" na ekranie działanie poszczególnych partii kodu). W zasadzie wyświetla tylko widoczną na screenie skrzyneczkę i jeszcze ją obraca. Kod wygląda tak:

procedure TGameLoop.Execute;
begin
  FreeOnTerminate:= False;
  while not (Terminated) or not (Application.Terminated) do
    begin
      Sleep(25);
      Synchronize(Action);
    end;
end;

procedure TGameLoop.Action;
var
  R: Integer;
  Transformer: TMatrix;
begin

  Transformer:= TMatrix.Create(Identity);
  Transformer.Fill(Multiply(Transformer.Grid, Rotation('Y', Angle * 1.0)));
  Transformer.Fill(Multiply(Transformer.Grid, Translation(0, 0, Distance)));
  Mesh.Vertices.Transformed:= TMatrix.Create(Multiply(Mesh.Vertices.Default.Grid, Transformer.Grid));
  Transformer.Free;

  Mesh.Faces.ReadCentroids;
  Mesh.Faces.ReadNormals;
  Mesh.Faces.TestVisibility(Camera);
  Mesh.Faces.ReadFlatIlluminance(Light);

  Mesh.Vertices.Projected:= TMatrix.Create(Multiply(Mesh.Vertices.Transformed.Grid, ProjectionMatrix.Grid));

  for R:= 0 to Mesh.Vertices.Projected.RowCount - 1 do
    begin
      Mesh.Vertices.Projected[R].Fill(Divide(Mesh.Vertices.Projected[R].Value, [Mesh.Vertices.Projected[R].W, Mesh.Vertices.Projected[R].W, Mesh.Vertices.Projected[R].W, 1]));
      Mesh.Vertices.Projected[R].Fill(Add(Mesh.Vertices.Projected[R].Value, [1, 1, 0, 0]));
      Mesh.Vertices.Projected[R].Fill(Multiply(Mesh.Vertices.Projected[R].Value, [ScreenWidth div 2, ScreenHeight div 2, 1, 1]));
    end;

  Mesh.Draw(Display, RenderMode);
  DrawInfo;

  Form1.SCR.Canvas.Draw(0, 0, Display.Bitmap);
  Mesh.Vertices.Transformed.Free;
  Mesh.Vertices.Projected.Free;
  Display.ResetBuffer;
end;

Sleep(25), żeby nie zamordować mojego procesora :). Osiągam dzięki temu w miarę płynne animacje, przy obciążeniu nieprzekraczającym 4%. Niemniej nie daje mi to oczywiście żadnej kontroli nad wyświetlaniem klatek, ani nawet mierzeniem FPS. Implementację właściwego kodu (z odpowiednim frame bufferem) chcę zrobić już w oparciu o Direct2D, ale postanowiłem, że zanim się za to zabiorę, muszę napisać jakiś prosty miernik prędkości, żeby móc określić, czy w ogóle zmierzam w dobrą stronę. A to dlatego, że jak donosi internet (stackoverflow czy MSDN), początkujący w Direct2D mają zazwyczaj spore problemy z osiągnięciem odpowiedniej wydajności (efekt wychodzi im nierzadko gorszy niż przy renderowaniu na CPU!), bo tam inaczej się zarządza buforami pamięci. Więc znowu, chcę tego uniknąć, ale bez miernika się nie obejdzie. Liczyłem, że być może Direct2D (na wzór mocniejszego brata Directa3D) ma własny miernik tego typu, ale w internecie nic nie wyszperałem, więc chyba nie ;/.

cerrato napisał(a):

Takie małe wtrącenie - dm nadzieję, że jak uda Ci się doprowadzić projekt do końca to się podzielisz z nami wynikiem swojej pracy :)

Spoko, do emerytury na pewno zdążę :). A tak serio, jak tylko uda mi się osiągnąć coś konkretniejszego to oczywiście, jestem skłonny udostępnić cały kod.

0
Crow napisał(a):

Mój obecny "game loop" jest bardzo prosty (bo sklecony "na kolanie", żeby móc "podziwiać" na ekranie działanie poszczególnych partii kodu). W zasadzie wyświetla tylko widoczną na screenie skrzyneczkę i jeszcze ją obraca. Kod wygląda tak: […]

Do testów spokojnie wystarczy. To co bym na ten moment zmienił to zawartość tej pętli, tak aby obsługę logiki wykonywać w ramach wątku pobocznego (obstawiam, że TGameLoop jest klasą wątku) i aby jedynie renderowanie finalnej bitmapy w oknie było synchronizowane.

Jeśli chcesz, możesz się wzorować na konstrukcji głównej pętli z mojego platformera. Wygląda tak:

procedure TScene.PlayScene();
begin
  repeat
    FrameBegin(); // pobiera stan zegara przed obsługą klatki

    UpdateInput(); // aktualizuje dane na temat klawiatury
    UpdateState(); // aktualizuje stan gry (stop, reset, power)

    if not Machine.Stopped then
    begin
      UpdatePerformance(); // aktualizuje framerate
      UpdateOptions();     // aktualizuje widoczność liczników
      UpdateLogic();       // aktualizuje logikę gry
      UpdateFrame();       // aktualizuje bufor ramki (generuje obraz klatki)
    end;

    UpdateWindowPlacement(); // aktualizuje rozmiar i pozycję okna
    UpdateWindowInterface(); // aktualizuje płótno okna (odmalowuje je)

    FrameEnd();  // pobiera stan zegara po obsłużeniu klatki i oblicza framerate
    FrameWait(); // oczekuje na kolejną klatkę
  until Finished or Interrupted or Machine.Unavailable;
end;

U Ciebie potrzeba mniej metod, bo zapewne cheat mode nie jest Ci potrzebny, tak samo jak możliwość pauzowania czy zamrażania pracy programu. Ale mimo wszystko możesz taką konstrukcję wykorzystać, dzieląc obsługę klatki na kilka metod:

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

  repeat
    FrameBegin(); // pobiera stan zegara przed obsługą klatki
    
    UpdateInput(); // aktualizuje dane na temat klawiatury
    UpdateState(); // aktualizuje stan gry (głównie power)
    
    UpdateLogic(); // aktualizuje logikę gry
    UpdateFrame(); // aktualizuje bufor ramki (generuje obraz klatki)
    
    UpdateWindowInterface(); // odmalowuje okno (używając Synchronize)
    
    FrameEnd();  // pobiera stan zegara po obsłużeniu klatki i oblicza framerate
    FrameWait(); // oczekuje na kolejną klatkę (używając np. Sleep)
  until Terminated or Application.Terminated;
end;

Sleep(25), żeby nie zamordować mojego procesora :)

Sleep ogólnie jest prywitywny i ma bardzo niską rozdzielczość (dokładność rzędu zaledwie 16ms), więc szału nima… Jeśli generowanie grafiki klatek jest bardzo wydajne (proste lub akcelerowane sprzętowo) to można z niego skorzystać, w przeciwnym razie trzeba kombinować.

Uniksy w tym temacie biją na głowę Windowsy ze względu na posiadanie funkcji zamrażającej program na określony czas liczony w mikrosekundach. Taka funkcja to skarb.

Osiągam dzięki temu w miarę płynne animacje, przy obciążeniu nieprzekraczającym 4%.

Podziękuj procedurze Sleep. :]

Niemniej nie daje mi to oczywiście żadnej kontroli nad wyświetlaniem klatek, ani nawet mierzeniem FPS.

Daje taką możliwość.

Zwróć uwagę, że główna pętla wykorzystuje mechanizm liczenia klatek, w którym jedna iteracja pętli to obsługa jednej klatki. To powoduje, że framerate jest równy liczbie wykonanych iteracji tej pętli w czasie jednej sekundy. A to powoduje, że wyliczenie framerate'u jest dziecinnie proste.

Zwróć uwagę na przykłady podane wyżej, a konkretnie na metody FrameBegin i FrameEnd. W mojej grze służą one do pobierania stanu sprzętowego zegara (inaczej licznika) i obliczania framerate'u. Zliczanie klatek wykonywanych w danej sekundzie bazuje na testowaniu bieżącej sekundy z systemowego zegara – po prostu wołana jest funkcja Now, z której następnie wyłuskuje się numer sekundy. Jeśli jest on taki sam jak w poprzedniej klatce to inkrementuje się licznik klatek, a jeśli jest inny (nowa sekunda), to zapamiętuje bieżący framerate i resetuje licznik. Proste i wygodne.

Jeśli nie chce Ci się takiego mechanizmu pisać samemu to możesz skorzystać z klasy, z której sam korzystam – źródła całego modułu wrzucam na Pastebin. Jedyne co potrzeba to utworzyć instancję klasy TClock i:

  • w metodzie FrameBegin wywołać TClock.UpdateFrameBegin,
  • w metodzie FrameEnd wywołać TClock.UpdateFrameEnd,
  • w metodzie FrameWait wywołać TClock.WaitForNMI.

To wszystko. Klasa udostępnia kilka podstawowych informacji dotyczących zegara:

  • TClock.Framerate – bieżący framerate (zmienia się co sekundę),
  • TClock.FrameLoad – bieżące obciążenie (aktualizowane co 12 klatek),
  • TClock.FrameIndex – numer klatki.

Jeśli chcesz mieć stałe przerwy pomiędzy klatkami, to usuń zawartość metody WaitForNMI i wstaw do niej swojego Sleepa. Pozostałe opcje możesz pominąć lub usunąć, tak samo jak kod specyficzny dla Uniksów.

Jeśli potrzebujesz bardzo precyzyjnego mechanizmu odmierzania czasu pomiędzy klatkami i utrzymywania stałego framerate'u, to niczego w tej klasie nie zmieniaj (poza ewentualnym wywaleniem kodu dla innych platform). To jednak oznaczać będzie zżeranie całej mocy jednego jądra CPU – coś za coś.

Implementację właściwego kodu (z odpowiednim frame bufferem) chcę zrobić już w oparciu o Direct2D, ale postanowiłem, że zanim się za to zabiorę, muszę napisać jakiś prosty miernik prędkości, żeby móc określić, czy w ogóle zmierzam w dobrą stronę.

Z Direct2D niestety nie pomogę, bo nie znam tej biblioteki i nie używam Delphi, no i w sumie to o profesjonalnym tworzeniu gier nie mam bladego pojęcia. :]

0

Poszedłem za radą i objąłem synchronizacją samo rysowanie, ale coś tu niestety nie działa. Dla testu - żeby wykluczyć wszystkie poboczne elementy, mogące sprawiać problemy - jak zwykle skrobnąłem sobie demko na boku:

type
  TLoop = class(TThread)
    BMP: TBitmap;
    constructor Create; overload;
    procedure Execute; override;
    procedure Draw;
  end;

constructor TLoop.Create;
begin
  BMP:= TBitmap.Create;
  BMP.SetSize(200, 200);
  inherited Create(True);
end;

procedure TLoop.Execute;
begin
  FreeOnTerminate:= True;
  repeat
    BMP.Canvas.Brush.Color:= RGB(Random(256), Random(256), Random(256));
    BMP.Canvas.FillRect(BMP.Canvas.ClipRect);
    Synchronize(Draw);
    Sleep(100);
  until
    Terminated or Application.Terminated;
end;

procedure TLoop.Draw;
begin
  Form1.SCR.Canvas.Draw(0, 0, BMP); //SCR to TPaintBox;
end;

Działanie bardzo proste: Co 100 milisekund losuje kolor i wypełnia nim bitmapę, którą następnie rysuje na Paintboxie. Odpalam, niby działa (zwykle tak przez ok 10-30 sekund), ale nagle jakby zamarza i przestaje zmieniać kolory (choć okno się nie zawiesza, a sama pętla nadal "chodzi"). Paintbox też działa i nadal można po nim mazać, więc wygląda to tak, jakby przestawał działać kod, który ma być synchronizowany. O co w tym chodzi, czemu tak się dzieje i co z tym zrobić?

0

Nie wiem co może być tego powodem – może TBitmap nie jest klasą bezpieczną do użycia w wątkach pobocznych i po jakimś czasie, w wyniku ”jakiejś” czynności po prostu zaczyna świrować? W dokumentacji może da się znaleźć informacje na ten temat.

Spróbuj może w ten sposób:

type
  TForm1 = class(TForm)
    procedure SCRPaint(Sender: TObject);
  {..}
  end;

type
  TLoop = class(TThread)
    BMP: TBitmap;
    constructor Create; overload;
    procedure Execute; override;
  end;

procedure TForm1.SCRPaint(Sender: TObject);
begin
  SCR.Canvas.Draw(0, 0, BMP); // tu malowanie bitmapy
end;

constructor TLoop.Create;
begin
  inherited Create(True);

  BMP:= TBitmap.Create;
  BMP.SetSize(200, 200);
end;

procedure TLoop.Execute;
begin
  FreeOnTerminate:= True;
  
  repeat
    BMP.Canvas.Brush.Color:= RGB(Random(256), Random(256), Random(256));
    BMP.Canvas.FillRect(BMP.Canvas.ClipRect);
    Synchronize(Form1.SCR.Invalidate);
    Sleep(100);
  until Terminated or Application.Terminated;
end;

Jeśli to niczego nie zmieni to widać generowanie bitmap też trzeba synchronizować.

Ewentualnie zastanów się nad tym, aby logikę przetwarzać w ramach głównego wątku – tak jak w moim platformerze. W OnShow formularza wywołaj AsyncCall metody uruchamiającej główną pętlę. Renderowania nie będziesz musiał synchronizować, ale aby kolejka komunikatów była przetwarzana, w każdej klatce wołaj Application.MessageBox.

0
furious programming napisał(a):

Nie wiem co może być tego powodem – może TBitmap nie jest klasą bezpieczną do użycia w wątkach pobocznych i po jakimś czasie, w wyniku ”jakiejś” czynności po prostu zaczyna świrować?

No racja! Przecież generując obrazek używałem TCanvas, które jest częścią VCL, które jak wiadomo, nie lubi się z wątkami pobocznymi. Faktycznie zsynchronizowanie także i tej części kodu, rozwiązało problem, tak samo jak generowanie bitmapy co prawda w dalszym ciągu na pobocznym wątku, ale nie przez TCanvas, tylko ScanLine.

Niestety, w moim silniku to nie pomaga. W wątku pobocznym zostawiłem same obliczenia geometryczne, a wszystko co tykało VCL (czyli w zasadzie jedna funkcja, która rysuje bitmapę na PaintBoxie), pozostawiłem do synchronizacji. Nic z tego, rysowało mi 1 może 2 klatki, a potem czarny ekran i koniec.

Postanowiłem więc sprawdzić, która część kodu "domaga się" synchronizacji. Wczytałem backup i zacząłem wycinać poszczególne linijki kodu z procedury Action (która jest synchronizowana) i przerzucać je do głównej procedury wątku pobocznego, czyli Execute. Wyszła z tego jakaś apokalipsa. Okazało się, że gdy część obliczeń macierzowych wykonuje wątek poboczny, a część synchronizuje do głównego, wszystko się po prostu wykrzacza...

Co jeszcze dziwniejsze, gdy po prostu usuwam synchronize i mam kod:

procedure TGameLoop.Execute;
begin
  FreeOnTerminate:= False;
  while not (Terminated) or not (Application.Terminated) do
    begin
      Sleep(25);
      Action;
    end;
end;

zamiast:

procedure TGameLoop.Execute;
begin
  FreeOnTerminate:= False;
  while not (Terminated) or not (Application.Terminated) do
    begin
      Sleep(25);
      Synchronize(Action);
    end;
end;

Działa... przez kilka sekund, dopóki wątki nie wejdą w konflikt co do dostępu do VCL. To pokazuje, że istnieje jakaś różnica między sytuacją, gdy kod znajduje się bezpośrednio w procedurze Execute, a sytuacją, gdy zamknięty jest w odrębnej procedurze (u mnie Action), która przez Execute jest jedynie wywoływana (nawet bez synchronizacji). Przyznam, że tego nie rozumiem.

===

Przerobiłem w taki sposób, że nadal wywołuję Action wewnątrz Execute, ale tym razem bez synchronize. Synchronizacja odpowiedniej części kodu następuje wewnątrz samego Action i to działa... tyle, że nie widzę żadnego wpływu na wydajność. Co natomiast przyspiesza działanie głównej pętli, to wywołanie całego Action nie przez synchronize, a przez queue (gołym okiem daje to z 15-20 klatek więcej).

0
Crow napisał(a):

Faktycznie zsynchronizowanie także i tej części kodu, rozwiązało problem, tak samo jak generowanie bitmapy co prawda w dalszym ciągu na pobocznym wątku, ale nie przez TCanvas, tylko ScanLine.

ScanLine Ci w niczym nie pomoże, bo o ile pozwala na dostęp do bloku pamięci zawierającego dane dotyczące pikseli, to wołając TBitmap.EndUpdate (a ten jest konieczny), klasa aktualizuje dane obrazu oraz modyfikuje zawartość płótna. Tak więc wychodzi na to samo. Jedyna różnica polega na tym, że ScanLine pozwala zmodyfikować dane w sposób efektywny.

Działa... przez kilka sekund, dopóki wątki nie wejdą w konflikt co do dostępu do VCL. To pokazuje, że istnieje jakaś różnica między sytuacją, gdy kod znajduje się bezpośrednio w procedurze Execute, a sytuacją, gdy zamknięty jest w odrębnej procedurze (u mnie Action), która przez Execute jest jedynie wywoływana (nawet bez synchronizacji). Przyznam, że tego nie rozumiem.

W sumie to również nie wiem dlaczego tak się dzieje i co za takie zachowanie odpowiada. Sprawdziłem jak to działa w Lazarusie – no nie ma żadnego problemu z takim programem, nawet jeśli renderowanie klatki oraz odmalowywanie okna wykonywane jest bez synchronizacji, nie ma też wycieków pamięci. Kod projektu i plik wykonywalny w załączniku.

Główna pętla wygląda tak:

procedure TPaintThread.Execute();
begin
  repeat
    GenerateBitmap();
    RedrawBox();

    Sleep(500);
  until Suspended or Terminated or Application.Terminated;
end;

natomiast kod używanych metod tak:

procedure TPaintThread.GenerateBitmap();
begin
  FFrame.Canvas.Brush.Color := RGBToColor(Random(256), Random(256), Random(256));
  FFrame.Canvas.FillRect(0, 0, 200, 200);
end;


procedure TPaintThread.RedrawBox();
begin
  FBox.Invalidate();
end;

FFrame to instancja klasy TBitmap, a FBox to komponent klasy TPaintBox, przekazywany do wątku w jego konstruktorze. No i wszystko śmiga. Jeśli dorzucę synchronizację to też śmiga:

procedure TPaintThread.Execute();
begin
  repeat
    Synchronize(Self, @GenerateBitmap);
    Synchronize(Self, @RedrawBox);

    Sleep(500);
  until Suspended or Terminated or Application.Terminated;
end;

I w czasie działania wątku pobocznego mogę okno przesuwać, przykrywać, minimalizować i przywracać, czyli forsować przemalowywanie okna, a mimo to wszystko nadal będzie działało prawidłowo. Oczywiście synchronizacja związana z operacjami na GUI jest sugerowana, ale dla testów sprawdziłem czy zadziała bez niej.

Tak więc albo wątki w Delphi lub VCL działają inaczej niż w Lazarusie, albo gdzieś jeszcze jest błąd, póki nie namierzony. Trudno mi powiedzieć.


Jeśli nie chcesz się póki co przejmować tym dlaczego wątek sprawia problemy i jego naprawę odłożyć na później, to zrezygnuj z wątku pobocznego i rób wszystko w głównym wątku – też będzie działać, nie zamrażając okna. Jeśli chcesz to Ci pokaże jak napisać taki kod.

1

W nowych Delphi chyba od Tokyo TBitmap podobno jest klasą Thread Safe (nie miałem okazji testować) ale od czegoś są metody Canvas.Lock i Canvas.Unlock więc może trzeba je wywoływać.
EDIT: Nie mam czasu sprawdzać ale to chyba chodziło tylko o FireMonkey
http://docwiki.embarcadero.com/RADStudio/Tokyo/en/Multi-Threading_for_TBitmap,_TCanvas,_and_TContext3D

0

@kAzek: metod Lock i Unlock używa się w wielowątkowych aplikacjach po to, aby ograniczyć możliwość malowania na płótnie do jednego wątku. Czyli jeden wątek maluje, a inne nie mogą. To w sumie standardowa technika synchronizacji, tak aby wiele wątków nie mogło zmodyfikować zawartości płótna jednocześnie, bo kod może się wykrzaczyć.

Problem jednak polega na tym, że program @Crow zawiera jeden wątek poboczny, który zajmuje się modyfikowaniem zawartości bitmapy. Jedyny problem może wystąpić wtedy, gdy wątek główny używa bitmapy (odczytuje) a poboczny w tym czasie renderuje zawartość (modyfikuje). Tyle że taka kolizja nie powinna zawieszać programu – co najwyżej na wpół zmodyfikowana bitmapa zostanie namalowana w komponencie. No ale ten testowy program jest zbyt prosty, a przygotowywanie bitmapy zbyt krótkotrwałe, aby doświadczyć takiej sytuacji (FillRect działa migiem).

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ć.

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.

2

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

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.

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...

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.

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.

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).

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.

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