Gdybym nadal używał nagłówków od PascalGameDevelopment w swoim projekcie, to pewnie musiałbym jeszcze wiele miesięcy czekać na ich przystosowanie do SDL3, które zostało wydane kilka dni temu. A tak to wczoraj zabrałem się za ich aktualizowanie i właśnie skończyłem robotę — dwa wieczory i gotowe.
Co prawda moje nagłówki nie zawierają wszystkiego co SDL3 oferuje, a tylko to czego faktycznie używam, ale mimo wszystko pracy nie było aż tak dużo. Teraz pozostało jeszcze przekopiować komentarze z ichniej dokumentacji i gotowe. Społeczność znacząco ułatwiła robotę takim jak ja, skrupulatnie dziergając dokument opisujący proces migracji kodu z SDL2 do SDL3 — okazał się bardzo pomocny.
Następnie zabiorę się za aktualizację kodu źródłowego mojego silnika. Tutaj pracy nie powinno być więcej niż na kilka wieczorów (na spokojnie), ale znaczących zmian (łamiących wsteczną kompatybilność) jest kilka, więc czeka mnie trochę przeróbek.
@miiiilosz: nie, bo nie ma to żadnego sensu — wszystko co jest potrzebne do migracji kodu znajduje się w przygotowanym dokumencie, więc można przez ten proces przejść bez większego bólu głowy. Ja i tak miałem ten luksus, że kodu mam relatywnie mało (~40kLoC), więc poprawek było niewiele i nie były zbyt trudne. W dużym projekcie, migracja kodu byłaby znacznie trudniejsza, ze względu na to, że niektóre rzeczy usunięto, a niektóre straciły kompatybilność wsteczną i trzeba by całkowicie zmienić implementację.
Nowa wersja gry Fairtris oficjalnie dostępna!
Release dla Windows 64-bit — Fairtris_2.0.0.20_release.zip
Ten wpis umieszczam tylko dla tych, którzy czytają mikroblogi, ale nie zaglądają do kategorii Oceny i recenzje, w którym znajduje się główny wątek poświęcony temu projektowi. W skrócie, nowa wersja zrywa kompatybilność z NES-em, wprowadza tylko najlepsze mechaniki, a także posiada odświeżoną szatę graficzną. Rozgrywka nie jest limitowana tak jak w pierwszym Fairtrisie, ale dodałem obsługę zglitchowanych kolorów, zgodnie z Tetrisem na NES-a. Killscreen znajduje się na poziomie 256
i zaimplementowany jest w formie specjalnego ekranu, wieńczącego zwycięstwo. Dojście do poziomu 256
jest najważniejszym wyzwaniem w tej grze — wymaga gry na najwyższej prędkości opadania klocków przez około półtorej godziny.
Miłej zabawy!
@furious programming nie pamiętam, że wtedy było takie. Teraz mi się to rzuciło w oczy.
Wczoraj pisałem wpis na blogu o prototypie głównej pętli gry, którą opracowałem na potrzeby swojego silnika, jendak usunąłem ten wpis, dlatego że istniał w nim co najmniej jeden problem, w wyniku którego późniejsze obliczenia — głównie zużycia CPU — stawały się nieprawidłowe. Lepiej nie rozpowszechniać błędnych kodów.
Dziś podszedłem do tematu jeszcze raz, ale tym razem zmieniając wymagania — o ile zmienna delta nie jest jakoś szczególnie problematyczna (szczególnie, jeśli jest dzielona na małe fragmenty w przypadku lagów), to lepiej jednak mieć stałą deltę. Być może przyda się to w przyszłości, a nawet jeśli nie, to przynajmniej znacznie uprości aktualizowanie logiki, co jest pożądane.
Pętla przede wszystkim powinna działać w taki sposób, aby liczba aktualizacji logiki w każdej sekundzie była stała, dostarczając deltę o stałej wartości do funkcji aktualizującej. Liczba aktualizacji musi posiadać wartość stanowiącą kompromis — nie może być mała, aby delta nie była zbyt duża, ani też duża, bo na potrzeby jednego renderowania, logika będzie aktualizowana po kilka razy (strata mocy CPU). Odpowiednią liczbą aktualizacji wydaje się poczciwe 60
.
Po drugie, liczba generowanych klatek powinna być limitowana, aby nie żreć CPU bez opamiętania i aby nie renderować po kilka razy tej samej klatki (skoro nic się nie zmieniło, to szkoda mocy na renderowanie tego samego). Jeśli gra zużywa mniej mocy niż oferuje CPU, to pozostały czas powinien być przespany. Im mniej roboty z generowaniem klatek, tym więcej czasu wątek gry powinien być zamrożony.
Po trzecie, liczba renderowanych klatek powinna być niezależna od liczby aktualizacji. Stała liczba aktualizacji logiki nie może być w żaden sposób powiązana z renderowaniem — te dwa mechanizmy powinny być zupełnie niezależne. Dla przykładu, logika renderowana jest 60x/s
, natomiast renderowanie 50x/s
. Gracz musi mieć możliwość obniżenia limitu klatek na sekundę — np. zamiast renderowania 60x/s
, może ustawić 30x/s
. Poza tym, w razie gdyby pecet był zbyt słaby, klatki powinny być renderowane rzadziej, a jeśli nastąpi lag, to wyrenderowana powinna być tylko jedna klatka — ta ostatnia, po wielokrotnym zaktualizowaniu logiki, aby nadgonić czas rzeczywisty.
Do zliczania tych wszystkich czasów, potrzebna jest spora kupka zmiennych i kilka stałych:
const
LOOP_FIXED_UPDATES = 60; // Liczba aktualizacji logiki na sekundę.
LOOP_FIXED_DELTA = 1.0 / GAME_LOOP_FIXED_UPDATES; // Stała delta przypadająca na klatkę (tu: 1/60).
LOOP_FRAMERATE_DEFAULT = 50; // Liczba klatek renderowanych na sekundę.
LOOP_REFRESHRATE_DEFAULT = 4; // Liczba aktualizacji dodatkowych liczników na sekundę.
var
Spinning: Bool; // Flaga podtrzymująca pętlę przy życiu.
FreqRendering: Int32; // Liczba renderowanych klatek na sekundę.
TicksPerSecond: UInt64; // Liczba ticków na sekundę.
TicksPerUpdate: UInt64; // Liczba ticków pomiędzy aktualizacjami logiki.
TicksPerRender: UInt64; // Liczba ticków pomiędzy renderowaniami klatek.
TicksPerRefresh: UInt64; // Liczba ticków pomiędzy odświeżeniami liczników.
TicksCurrent: UInt64; // Liczba ticków przed rozpoczęciem generowania klatki.
TicksLastUpdate: UInt64; // Liczba ticków podczas poprzedniej aktualizacji logiki.
TicksLastRender: UInt64; // Liczba ticków podczas poprzedniego renderowania klatki.
TicksLastRefresh: UInt64; // Liczba ticków podczas poprzedniego odświeżenia liczników.
TicksAccuUpdate: UInt64; // Akumulator zliczający ticki na potrzeby aktualizacji logiki.
TicksAccuRender: UInt64; // Akumulator zliczający ticki na potrzeby renderowania klatek.
TicksAccuRefresh: UInt64; // Akumulator zliczający ticki na potrzeby odświeżania liczników.
Wspomniane wyżej „odświeżanie liczników” dotyczy dodatkowych liczników, np. mierzących framerate, obciążenie CPU i inne rzeczy potrzebne do debugowania (to jest opcjonalne).
Zanim główna pętla wystartuje, trzeba zainicjalizować zmienne. Najlepiej w miejscu deklaracji wszystki wyzerować (co mam u siebie, ale nie w powyższym snippecie), jednak wiele z nich musi być ustawionych w runtime.
// Ustawienie częstotliwości renderowania i odświeżania liczników.
FreqRendering := LOOP_FRAMERATE_DEFAULT; // 60
FreqRefreshing := LOOP_REFRESHRATE_DEFAULT; // 4
// Obliczenie liczby ticków przypadających na poszczególne czynności (logika, renderowanie, odświeżanie).
TicksPerSecond := SDL_GetPerformanceFrequency();
TicksPerUpdate := Round(TicksPerSecond * LOOP_FIXED_DELTA);
TicksPerRender := Round(TicksPerSecond / FreqRendering);
TicksPerRefresh := Round(TicksPerSecond / FreqRefreshing);
// Inicjalizacja liczników ticków dla poprzednio wykonanych poszczególnych czynności.
// Dzięki odejmowaniu ticków, pierwsza iteracja pętli wymusi wykonanie zadanych trzech czynności.
TicksCurrent := SDL_GetPerformanceCounter();
TicksLastUpdate := TicksCurrent - TicksPerUpdate;
TicksLastRender := TicksCurrent - TicksPerRender;
TicksLastRefresh := TicksCurrent - TicksPerRefresh;
Skoro wszystko jest zainicjalizowane, pętla startuje i obraca się dotąd, aż flaga podtrzymująca życie pętli zostanie zgaszona.
repeat
// Pobranie liczby ticków przed wygenerowaniem klatki.
TicksCurrent := SDL_GetPerformanceCounter();
// Aktualizacja akumulatorów, ale tylko wtedy, gdy upłynęło wystarczająco dużo czasu.
if TicksCurrent > TicksLastUpdate then TicksAccuUpdate += TicksCurrent - TicksLastUpdate;
if TicksCurrent > TicksLastRender then TicksAccuRender += TicksCurrent - TicksLastRender;
if TicksCurrent > TicksLastRefresh then TicksAccuRefresh += TicksCurrent - TicksLastRefresh;
// Sprawdzenie, czy nadszedł czas wykonania co najmniej jednej czynności.
if (TicksAccuUpdate > TicksPerUpdate) or (TicksAccuRender > TicksPerRender) or (TicksAccuRefresh > TicksPerRefresh) then
begin
// Sprawdzenie, czy nadszedł czas aktualizacji logiki (możliwa wielokrokowa aktualizacja).
while TicksAccuUpdate > TicksPerUpdate do
begin
// Przetworzenie zdarzeń i aktualizacja logiki o stały krok.
UpdateEvents();
UpdateLogic(LOOP_FIXED_DELTA);
// Zapamiętanie czasu najświeższej aktualizacji i dekrementacja akumulatora.
TicksLastUpdate += TicksPerUpdate;
TicksAccuUpdate -= TicksPerUpdate;
// Inkrementacja ogólnego licznika zaktualizowanych klatek.
NumFramesUpdated += 1;
end;
// Sprawdzenie, czy nadszedł czas renderowania.
// Nieważne ile czasu minęło od poprzedniego renderowania, malujemy tylko najświeższą klatkę.
if TicksAccuRender > TicksPerRender then
begin
// Wyrenderowanie klatki i wyrzucenie obrazu na ekran.
Game_RenderFrame();
Game_RenderPresent();
// Zapamiętanie czasu najświeższego renderowania i nadgonienie czasu.
while TicksLastRender < TicksCurrent do TicksLastRender += TicksPerRender;
while TicksAccuRender > TicksPerRender do TicksAccuRender -= TicksPerRender;
// Inkrementacja ogólnego licznika wyrenderowanych klatek.
NumFramesRendered += 1;
end;
// Sprawdzenie, czy nadszedł czas aktualizacji ogólnych liczników.
if TicksAccuRefresh > TicksPerRefresh then
begin
// Tutaj kod aktualizujący ogólne liczniki.
// Zapamiętanie czasu najświeższego odświeżenia liczników i nadgonienie czasu.
while TicksLastRefresh < TicksCurrent do TicksLastRefresh += TicksPerRefresh;
while TicksAccuRefresh > TicksPerRefresh do TicksAccuRefresh -= TicksPerRefresh;
// Inkrementacja ogólnego licznika odświeżeń liczników (opcjonalnie).
NumFramesRefreshed += 1;
end;
end
else
// Jest za wcześnie na wykonanie którejkolwiek czynności, więc czekamy milisekundę.
SDL_Delay(1);
until not Spinning;
Najpierw pobiera się bieżącą liczbę ticków, a następnie aktualizuje akumulatory, jeśli upłynęło wystarczająco dużo czasu. Jeśli nie upłynęło, to liczniki zapamiętujące poprzednie wykonania będą miały wartość większą od czasu obecnego, a więc wynikiem odejmowania byłaby wartość ujemna, co w przypadku używania typów UInt64
(bez znaku) skończyłoby się błędem „integer underflow”.
Następnie wykonywany jest główny warunek, który sprawdza, czy nadszedł czas wykonania którejkolwiek czynności (albo kilku). Jeśli tak, to najpierw aktualizuje się logikę — w pętli, bo mógł pojawić się lag, przez co konieczne jest nadgonienie czasu. Następnie renderuje się klatkę i wywala obraz na ekran. Renderowana jest tylko jedna klatka, nieważne czy był lag czy nie — tylko najświeższa. Jeśli trzeba, aktualizuje się ogólne liczniki — też tylko raz, nieważne czy lag był czy nie. Głównie chodzi o licznik fps-ów, aby gracz widział czy mu CPU wyrabia, czy jednak gra gubi klatki.
Stałe zostały ustawione tak, aby logika była aktualizowana 60x/s
, klatki były renderowane 50x/s
, a stan liczników aktualizowany 4x/s
. Trzy czynności, każda wykonywana w różnych (niezależnych) odstępach czasu. Dopisałem trochę kodu aby pomierzyć i wyświetlić stan liczników co sekundę — niżej przykład:
Iterations: 610 | Updated: 60 | Rendered: 50 | Refreshed: 4
Iterations: 604 | Updated: 60 | Rendered: 50 | Refreshed: 4
Iterations: 611 | Updated: 60 | Rendered: 50 | Refreshed: 4
Iterations: 607 | Updated: 60 | Rendered: 50 | Refreshed: 4
Pierwsza kolumna pokazuje liczbę iteracji pętli repeat until
, druga wyświetla NumFramesUpdated
, trzecia NumFramesRendered
, ostatnia NumFramesRefreshed
. Wszystko się zgadza, wedle założeń. Teraz wprowadźmy niewielki lag (symuluję łapiąc za belkę tytułową okna):
Iterations: 618 | Updated: 60 | Rendered: 50 | Refreshed: 4
Iterations: 327 | Updated: 33 | Rendered: 27 | Refreshed: 2 // lag
Iterations: 567 | Updated: 87 | Rendered: 47 | Refreshed: 5 // lag
Iterations: 614 | Updated: 60 | Rendered: 50 | Refreshed: 4
Jak widać, lag obniża liczbę renderowanych klatek (renderowanie nieaktualnych klatek jest pomijane), ale liczba aktualizowanych klatek zgadza się — w zalagowanej sekundzie aktualizacji jest mniej (tu: 33
), a w następnej sekundzie wykonywane są brakujące aktualizacje (tu: 87
), czyli wyrównanie do czasu rzeczywistego. Teraz potężny lag, taki na kilka sekund:
Iterations: 611 | Updated: 60 | Rendered: 50 | Refreshed: 4
Iterations: 144 | Updated: 15 | Rendered: 11 | Refreshed: 1 // lag
Iterations: 552 | Updated: 165 | Rendered: 46 | Refreshed: 4 // lag
Iterations: 611 | Updated: 60 | Rendered: 50 | Refreshed: 4
Znów następuje nadgonienie, tym razem w zalagowanej sekundzie aktualizacji jest 15
, potem kilka sekund przerwy, a następnie duże nadgonienie — tutaj aż 165
klatek. Dlaczego tyle? Bo 15 + 165 = 180
, a to dzieli się przez 60
bez reszty (czyli przez zadaną, stałą liczbę aktualizacji na sekundę).
Podana wyżej implementacja głównej pętli wygląda na poprawną. Na razie do testów mam tylko konsolę i logowanie w niej danych — póki nie mam jeszcze silnika, trudno powiedzieć jaki będzie efekt widoczny na ekranie. Choć obecnie nie mam podstaw by sądzić, że klatki na ekranie nie będą wyświetlane w równych odstępach (przy stabilnym działaniu).
Napisałbym dużo więcej na temat tej pętli, ale… limit znaków wpisów na mikroblogach. :D
Determinizm mozna osiagnac poprzez wyciagniecie seeda + czestotliwosc odswiezania w tym wypadku. Jednak rozklad randomowych framedropow na pewno nie jest deterministyczny ani staly, dlatego mowienie o determinizmie w takim przypadku jest i tak uproszczeniem.
@winuser: nie da się zaprogramować w pełni deterministecznej symulacji ze zmienną deltą. Stała delta jest jedynym rozwiązaniem tego problemu, dlatego też współczesne duże silniki taką zapewniają (np. Unity i jego FixedUpdate
). Nie musisz mi wierzyć — poczytaj na ten temat artykuły, traktujące o wzorcach używanych w gamedevie.
W kodzie, który podałem we wpisie, logika aktualizowana jest w stałych interwałach, czyli jest to odpowiednik FixedUpdate
z Unity. Sam nie potrzebuję, ale jeśli bym chciał, to mogę sobie dodać również odpowiednik zwykłego, nieregularnego Update
— wystarczy taką funkcję wywołać w każdej iteracji pętli repeat until
, czyli tuż przed głównym warunkiem (tym testującym trzy akumulatory).
Wczoraj siedziałem sobie na ławeczce w parku, grzejąc się do słoneczka i relaksując się w otoczeniu kolorowych, szumiących drzew, rozmyślałem na temat różnych ficzerów, które docelowo chciałbym umieścić w swojej grze. Jednym z nich jest dźwięk przestrzenny, którego dość prosty silnik zamierzam zaimplementować. I tutaj być może istnieje pewien nieco paradoksalny fenomen, którego na razie nie jestem w stanie sprawdzić w praktyce (silnik dźwięków będę implementował najwcześniej za rok). Na razie to kwestia czysto filozoficzna.
Docelowy silnik graficzny w mojej grze, czyli ten odpowiedzialny za obsługę trójwymiarowej mapy, będzie renderowany w formie rzutu ortograficznego, co da efekt podobny do retro gier z gatunku action adventure (jak często wspominana przez mnie Zelda z konsoli SNES). Teren ze swoją zawartością będzie na ekranie widoczny z jednej strony jako płaski (dzięki braku wsparcia perspektywy), ale z drugiej strony jako trójwymiarowy (dzięki oświetleniu/światłocieniowi). Koniec końców, efekt widoczny na ekranie będzie bliski przestrzeni dwuwymiarowej. To powoduje, że trzeci wymiar, czyli pozycja w osi Z
(upraszczając, pozycja obiektu nad gruntem) będzie miała niewielkie znaczenie dla systemu dźwięków.
Tak więc istotne dla systemu dźwięku przestrzennego będą dwie osie (osie X
i Y
), co znacznie lepiej pasuje do dźwięku stereo — dwie osie kierunków dla dwóch kanałów audio.
Aby stworzyć prosty dźwięk przestrzenny, wystarczy odpowiednio operować głośnością lewego i prawego kanału. Im dalej od bohatera znajduje się źródło dźwięku, tym bardziej należy przyciszyć oba kanały. Druga sprawa — im bardziej po lewej stronie znajduje się źródło, tym ciszej musi grać prawy kanał (i vice versa). Tutaj obsługuje się dwie właściwości — dystans od źródła dźwięku oraz kierunek, z którego dźwięk nadlatuje, obie funkcje za pomocą jednego suwaka. Do tego SDL2 posiada funkcję Mix_SetPanning
. Oczywiście są też funkcje Mix_SetPosition
oraz Mix_SetDistance
, ale ich użycia na razie nie rozważam, bo skupiam się na aspektach niskopoziomowych i zrozumieniu problemu.
Powyższe załatwia nam na razie jedną oś — lewo i prawo, jako głośność lewego i prawego kanału. Ale to dopiero pierwszy krok.
Dźwięk przestrzenny to nie tylko panning. Człowiek inaczej słyszy dźwięk jeśli jego źródło jest przed nim, a inaczej jeśli jest za nim. Dodatkowo, inaczej też słyszy dźwięk jeśli jego źródło jest wysoko, a inaczej jeśli nisko (uwzględniając stałe położenie głowy) — to jednak oddrzucam, dlatego że nie ma zastosowania w moim silniku, ze względu na rzut ortograficzny.
Skupiając się na kierunku przód-tył — tego nie da się rozwiązać panningiem, tutaj potrzebne jest filtrowanie sygnału. Jeśli dźwięk dochodzi z przodu słuchacza, nie filtrujemy sygnału, a im bardziej ucieka do tyłu, dla przykładu tym bardziej go tłumimy (np. silniej obcinamy wysokie częstotliwości). Dźwięk dochodzący całkiem z tyłu słuchacza powinien się znacząco różnić od tego dobiegającego od frontu — musi być wyraźna różnica w jakości, ale nie jakoś przesadnie duża.
Filtrowanie również stosujemy dla kierunków lewo-prawo — im bardziej źródło dźwięku ucieka na lewo, nie tylko tym mocniej przyciszamy prawy kanał, ale też mocniej go filtrujemy. To da jeszcze większą głębię, większą różnorodność i łatwość w określaniu kierunku, z którego dźwięk dochodzi.
Nie wiem jeszcze dokładnie w jaki sposób w SDL2 można zaimplementować przetwarzanie kanałów na żywo (ale mam na to dużo czasu, więc nie ma spiny). Jednak po dość pobieżnym przeglądnięciu API, wygląda na to, że służą do tego efekty i callbacki, w których dostarczane są bufory do obróki, przed ich ostatecznym zmiksowaniem i wysłaniem do karty dźwiękowej. A skoro tak, to implementacja całego silnika dźwiękowego w opracowanej formie nie będzie mocno skomplikowana (choć wymagać pewnie będzie nastukania kilku tysięcy linijek, ale to nie jest dla problemem).
Opisane wyżej manipulacje głośnością dźwięków oraz ich filtrowanie, da co prawda pewną ich przestrzenność, ale można temat jeszcze pociągnąć i dodać więcej prostych ficzerów, jeszcze bardziej urozmaicających udźwiękowienie i dodających mu jeszcze większej głębi. Rozważam nad jeszcze kilkoma efektami:
Tutaj mam zagwozdkę — czy mogę podejść do tematu luźno i stworzyć dowolne filtry przetwarzające sygnały dźwiękowe, aby uzyskać dobry efekt przestrzenny, czy muszę skorzystać z jakichś konkretnych, bo inaczej to stworzona przestrzenność będzie wybijać z imersji lub wręcz irytować gracza?
Ludzie potrafią się szybko przyzwyczajać to dziwnych, wręcz topornych mechanizmów istniejących w grach. Czy powszechne przyzwyczajenie do dźwięku stereo powoduje, że gra z dźwiękiem mono przeszkadza? IMO nie przeszkadza. Czy przyzwyczajenie do responsywnego sterowania uniemożliwia granie w gry z bardziej topornym sterowaniem i powoduje, że gracz odbija się i nie jest w stanie go opanować? IMO też nie, bo jest masa gier z gównianym sterowaniem, do którego gracze — choć z początku negatywnie nastawieni — są w stanie przywyknąć i go z biegiem czasu wymasterować.
Do czego dążę — czy ludzie przyzwyczajeni do naturalnych, przestrzennych dźwięków z życia oraz rewelacyjnych systemów dźwiękowych w grach AAA są w stanie przywyknąć do dźwięku przestrzennego, który de facto jest przestrzenny, ale który wykorzystuje filtrowanie tylko podobne do naturalnego lub wręcz w ogóle z nim niezgodne? Czy będą w stanie przyzwyczaić się do niego tak szybko jak np. do topornego sterowania czy specyfinczej oprawy graficznej?
Jestem bardzo ciekaw, czy w temacie filtrowania mam pewną dowolność, czy jednak muszę iść w kierunku jak najdokładniejszego odwzorowania jakości dźwięku istniejącego w prawdziwym otoczeniu. Czy mogę — brzydko pisząc — na pałę wybrać sobie jakiekolwiek sensowniejsze filtrowanie dla lewo-prawo i przód-tył, nie psując graczowi odbioru gry? Czy istnieje tutaj paradoks, pewien fenomen związany z ludzkimi zdolnościami adaptacyjnymi, czy nie istnieje?
Za pomocą panningu oraz prostego filtrowania kanałów, można bardzo łatwo uzyskać namiastkę przestrzenności dźwięku. Pierwsze co należy zrobić to oczywiście zaimplementować silnik dźwiękowy w taki sposób, aby obsłużyć głośność kanałów stereo oraz dać możliwość filtrowania każdego dzięku, którego źródło znajduje się wewnątrz świata gry (dźwięki UI powinny być odtwarzane w sposób standardowy).
Wszystko to musi być zaprogramowane w taki sposób, aby dało się ten system kalibrować. Implementację należy uprościć tak, aby za pomocą kilku stałych (np. znormalizowanych floatów jako mnożników/thresholdów) móc łatwo manipulować siłą wyciszenia dźwięku wraz ze wzrostem odległości od słuchacza oraz siłą filtrowania. Po określeniu idealnych wartości tych stałych, więcej nie trzeba będzie tych stałych dotykać (w końcu kalibruje się raz na zawsze).
Ustawianie głośności, panningu i filtrowanie związane z kierunkami, z którym dochodzą dźwięki, nie jest trudne. Callback musi kuknąć do mapy, sprawdzić odległość i kąt względem słuchacza, a to pikuś. Tłumienie względem dużych przeszkód, znajdujących się pomiędzy źródłem dźwięku a słuchaczem też nie wydaje się trudne — prosty ray casting ze źródła dźwieku w stronę bohatera, detekcja duzych obiektów i wyliczenie znormalizowanego współczynnika wytłumienia. Echo będzie znacznie trudniejsze, bo o ile łatwo jest sprawdzić czy źródło dźwięku znajduje się w zamkniętym obiekcie, o tyle trudniej będzie napisać filtr nakładający echo na próbkę. Podobnie z efektem Dopplera, bo tutaj zmienić należy nie jakość dźwięku, a jego częstotliwość. Ale hej, Google na pewno da odpowiedzi. :D
Coż, temat ten od jakiegoś czasu nie daje mi spokoju. Trudno mi zaspokoić ciekawość, bo prócz możliwości dogłębnego przemyślenia tematu, nie mam na razie możliwości sprawdzenia tych teorii. Oczywiście mógłbym się pobawić różnymi narzędziami i zrobić sobie prototyp, jednak czym innym jest oderwany od projektu prototyp, a czym innym gotowa implementacja do zabawy w docelowym silniku.
Na razie więc kwestia przestrzenności dźwięku oraz teorii domniemanej dowolności w filtrowaniu pozostanie zagadką. :]
Ogólnie warto trochę poeksperymentować i pamiętać, że najłatwiejszą metodą dla symulacji lokalizacji dźwięku są osobne wyliczania odległości do ucha lewego i prawego w celu ustalenia odpowiedniego przesunięcia odtwarzania kanałów dla każdego ucha. Ważne jest także odpowiednie tłumienie wysokich częstotliwości. "Najtańsza" implementacyjnie metoda to filtrowanie wysokich częstotliwości (np. w paśmie 6kHz - 20khz obcinać odpowiednio np. od -3dB do -8dB) dla ucha po przeciwnej stronie głowy względem źródła. Jeśli źródło dźwięku jest po lewej stronie to do prawego nie tylko dźwięk dociera ciszej i później ale także o innej charakterystyce częstotliwościowej. Analogicznie przód/tył. Ze źródła z tyłu głowy słyszymy mniej najwyższych częstotliwości.
Druga sprawa to szerokość głowy a ta ma około 20cm. Dźwięk tę odległość przemierza około 1,5ms a ucho takie różnice już doskonale wyłapuje jako zaburzenia lokalizacji źródła dźwięku. Warto więc dźwięk dla jednego ucha przesuwać w tym małym zakresie bo to też dużo da (oczywiście uwzględniając kąt względem źródła). Zrób sobie sztuczną głowę z gąbki w miejsce uszu włóż mikrofony i pobaw się tematem np. w Audacity... Do tego wszystkiego jeszcze niestety dochodzą odbicia od obiektów w pomieszczeniu, które odgrywają chyba najważniejszą rolę ale wyliczenie tego już nie jest takie banalne.
@katakrowa: bardzo dobre podsumowanie napisałeś. :]
W moim przypadku, silnik dźwięku będzie okrojony do kilku efektów, dlatego że bardziej niż na absolutny realizm, stawiam na prostotę. Bo tak jak wskazałeś, faktycznie w tym temacie jest masa różnych czynników, jednak ich wszystkich nie opłaca się implementować, ze wzlgędu na specyfikę mojej gry (w końcu będzie to nieduża gra w pixelarcie). Mimo wszystko wszystkiemu o czym napisałeś dokładniej przyjrzę się podczas implementacji tego silnika i wtedy się zobaczy, jak dużo można zrobić, a co okaże się trudne lub zbędne. Na razie tylko teoretyzuję i przygotowuję głowę na przyszłość.
Zrezygnowałem z funkcji moderatora na 4programmers. Już od jakiegoś czasu o tym myślałem i dziś porzuciłem tę funkcję — dzięki temu będę miał nieco więcej spokoju oraz czasu na pracę nad swoim projektem gry, który jest dla mnie teraz najwazniejszy. Zanim jednak rozpęta się drama, od razu zaznaczę, że nie odchodzę z 4programmers — nadal zamierzam tu być, w wolnym czasie udzielać się w dyskusjach i smarować bzdurne wpisy na blogu. Jedyne co się zmienia to brak dostępu do narzędzi moderatorskich. ;)
A skoro już wspomniałem o projekcie gry, to troszkę niusów niżej.
Przez ostatnie 8-10 lat, kod w IDE miałem w skali szarości — niemal czarne tło oraz różne identyfikatory i literały jako jasno- lub ciemnoszare, słowa kluczowe i dyrektywy jako białe itd. Taki schemat kolorów był wystarczająco czytelny i nie męczył oczu. Jednak od dawna zanosiłem się z ustawieniem kodu kolorowego, bo zazdrościłem użytkownikom np. Visual Studio tego, że tak ciekawie, ładnie i czytelnie wygląda kod w tym IDE (i w wielu innych). Ale za każdym razem gdy kolorowałem kod w Lazarusie, wyglądał on beznadziejnie — z jednej strony raczej słabą przez funkcjonalność kolorowania składni w edytorze, a z drugiej strony przez brak sensownego pomysłu na kolorystykę. Za każdym razem wracałem więc do skali szarości.
Kod zawsze staram się pisać w taki sposób, aby sam się komentował, dlatego nigdy nie pisałem komentarzy, nawet jeśli projekt był relatywnie duży. Np. Richtris, który zawiera ponad 28 tysięcy linijek kodu, nie ma ani jednego komentarza (prócz nagłówków z informacjami o autorze). Przy okazji widać kawałek edytora kodu i kolorowanie składni, z jakiego korzystałem przez ostatnie lata (widać niezbyt wiele, ale innego zrzutu nie mam):
Czasem wracam do tego projektu i kopuję z niego jakieś rozwiązania na potrzeby innych projektów, ale pomimo tego, że jego kodu nie roszałem od dobrych dwóch lat, nie mam większego problemu ze zrozumieniem jak działa. Jednak to tylko pozory — o ile widzę co kod robi, to mogę nie wiedzieć dlaczego coś jest wykonywane, a tego sam kod mi już nie powie. No i dupa.
Swój nowy projekt postanowiłem pisać w ten sam sposób — porządnie formatować kod i pisać go w tak, aby było wiadomo jak działa. W teorii tyle wystarczy, jednak pisząc kod niskopoziomowy operujący na wskaźnikach, jego działanie może na pierwszy rzut oka nie być wcale takie oczywiste. Projekt rozrósł się dość mocno, obecnie mam ~6,500 LoC, rozsianych po 72 plikach. Do tej pory bez problemu ogarniałem jego strukturę i nie miałem żadnych problemów z jego zrozumieniem, jednak obawiałem się, że wraz z rozwojem projektu, kodu przybędzie dziesiątki tysięcy linijek, zacznę się w nim gubić i tracić czas na rozgryzanie tego jak działa. Postanowiłem więc, że jednak zacznę pisać komentarze, chyba po raz pierwszy w życiu. :D
Jeśli już je pisać, to najlepiej zrobić to już, póki jeszcze kodu jest relatywnie mało i wiem dokładnie jak działa. Zacząłem je pisać, szukając jakiegoś dobrego schematu i już na wstępie byłem rozczarowany. Po pierwsze tym, że to cholernie czasochłonne i nudne, a po drugie tym, że kod stał się przez te komentarze nieczytelny. Moje ówczesne, szaro-bure kolorwanie składni za mocno eksponowało komentarze, a za słabo eksponowało właściwy kod. To ostatecznie przesądziło o tym, aby w końcu zmienić kolorystykę kodu, bo inaczej utonę w tych komentarzach. :|
Dawniej przeglądałem w Google zrzuty z różnych IDE, szukałem inspiracji w paczkach dla VS, więc mniej więcej wiedziałem co i jak pokolorować. Niestety Pascal ma dość dziwną składnię w porównaniu np. do C++ czy Javy, a do tego Lazarus nie daje jakoś szczególnie dużo opcji związanych z różnymi elementami kodu. Ostatecznie zainpirował mnie highlighter używany w Stack Overflow, wykorzystujący tylko kilka kolorów — biały, pomarańczowy, zielony i niebieski, wszystko na niemal czarnym tle. Lubię wszystkie te kolory, szczególnie połączenie białego, pomarańczoweto i zielonego (jak dawne bolidy Force India w F1). Poeksperymentowałem trochę z tym który kolor do czego przypisać i jakie odcienie będą dobre, zważywszy na składnię Pascala, częstotliwość wystepowania danego koloru w docelowym kodzie oraz na jasność i konstrast używanych przez mnie wyświetlaczy. No i w końcu udało się — nareszcie mam kolory kod w IDE, który mnie nie obrzydza i który ładnie eksponuje właściwy kod, utrzymując komentarze nie rzucające się zbytnio w oczy:
Jeśli chodzi o same komentarze, to początkowo postanowiłem komentować tylko niektóre rzeczy — w nagłówku pliku ogólnie opisać co dany moduł zawiera, a w samym kodzie dodawać komentarze tylko z informacjami, o których sam kod nie mówi (czyli dlaczego coś jest wykonywane, dlaczego tak a nie inaczej itd.). Szybko jednak doszedłem do tego, że jednak lepiej jest opisać każdy aspekt i każdy element (ale bez przesady), bo w przyszłości nawet takie z pozoru oczywiste rzeczy mogą przestać być oczywiste i każdy komentarz będzie na wagę złota, bo pozwoli oszczędzić czas analizy kodu. Tak więc oprócz podawania ogólnych informacji, dodawałem komentarze również do deklaracji typów danych, stałych, nagłówków funkcji i opisywać ich argumenty, podawać tez nieoczywiste rzeczy oraz ważne informacje, o których sam kod nigdy nie powie. Nadźgałem tych komentarzy dość sporo i początkowo byłem sceptyczny, jednak po kilku dniach czuję, że komentarze są bardzo ważne i że na pewno w przyszłości pomogą mi utrzymywać kod. Tak więc jestem z nich zadowolony, bo był to krok w dobrą stronę. Szkoda tylko, że mój angielski trochę posysa i że często muszę z translatora korzystać — ale to nie jest problem, bo komentowanie idzie sprawnie, a korzystanie z translatora mimo wszystko pomaga uczyć się angielskiego.
@scibi_92: Nie no nie ma co popadać w skrajności, ja tam nie piszę na wskaźnikach dodaje sobie po prostu dodaje sobie handlery do eventow i mam stan dla GUI, ktory im przekazuje.
I to moze podpada troche pod obiektowe programowanie, ale nigdzie nie wrzucam, wzorcow, wiekszosc rzeczy jest funkcyjnie. Obiektami moze są nodes GUI, ale wszystkie handlery to po prostu funkcje bez zadnych dekoratorow itp. Kod jest po prostu prosty, Jedna funkcja(może rozbijam na kilka logicznych funkcji czasem) buduje mi okienko, przypisuje odpowiednim elementom odpowiednie akcje i tyle :)
Wskaźniki to trzeba było używac w GTK dla C :P
Piszę dalej kod mappera, tym razem właściwą funkcję pozwalającą wykryć moment odpalenia danej akcji (jeden z wielu momentów tak naprawdę). Czyli po stronie kodu obsługi logiki gry mam do dyspozycji wysoce abstrakcyją metodę do sprawdzania czy gracz wykonał konkretną akcję. Przykład wywołania akcji wejścia do menu:
if Game_InputIsPerformed(GAME_INPUT_PLAYER_1, GAME_INPUT_ACTION_MENU, GAME_INPUT_MOMENT_STARTED, nil) then
// hura.
Powyższy kod testuje czy konkretny gracz (tutaj: pierwszy) wykonał konkretną akcję (tutaj: wejścia do menu), sprawdzając również moment wykonania akcji (tutaj: świeże odpalenie akcji). Natomiast po drugiej stronie, maskowane przez tę abstrakcyjną funkcję, mamy kupę urządzeń wejścia, trybów ich używania, różnych manipulatorów (przyciski, rolki, osie, drążki i inne c**** muje). To z którego urządzenia, jakim i którym manipulatorem się odpala akcję i w jaki sposób, determinuje konfiguracja ”obiektu” danej akcji.
Przed chwilą skończyłem programować ostatni sposób aktywowania akcji za pomocą kontrolera, czyli za pomocą gałki analogowej. Natrafiłem tutaj na ciekawy problem — czasem wychylenie drążka analogowego nie jest wykrywane.
Sprawa wygląda tak — każda oś analogowa reprezentowana jest przez obiekt stanu (w formie struktury z kilkoma polami), dzięki czemu zawsze mam dostęp do wartości poprzedniej i aktualnej. W każdej klatce zasysam dane ze zdarzeń SDL_JOYAXISMOTION
i wpisuje je do odpowiednich obiektów ze stanem osi. Aby sprawdzić, czy dana oś jest odpowiednio mocno wychylona (czyli aby sprawdzić, czy akcja jest świeżo aktywowana), należy:
Jest to proste i logiczne, nic nadzwyczajnego. Jednak tutaj jest właśnie problem — dość regularnie świeże aktywowanie akcji nie jest wykrywane. Wszystko dlatego, że ruszanie gałką analogową emituję ogromne ilości zdarzeń, przez co podczas pobierania zdarzeń z kolejki w ramach danej klatki, w kolejce znajdują dziesiątki zdarzeń SDL_JOYAXISMOTION
. Nie można ot tak przepisywać z nich dane, bo obiekt stanu osi zapamiętuje tylko dwie wartości (poprzednią i aktualną), a więc z tych dziesiątek zdarzeń zassanych z kolejki, zapamięta dane ostatnich dwóch. Wartości drążków z tych dwóch ostatnich zdarzeń mogą być większe niż threshold i dupa — świeże odpalenie akcji nie ma prawa być wykryte.
Rozwiązałem to w ten sposób, że do obiektu stanu dodałem sobie opcjonalny akumulator. Służy on jako tymczasowy bufor, do którego można pakować dane bez opamiętania — zapamiętywana jest tylko ostatnio dodana wartość (poprzednia się nadpisuje). Ten akumulator gromadzi dane niezależnie od stanu poprzedniego i aktualnego. Teraz, kiedy przetwarzane są zdarzenia danej klatki, zamiast przepisywać wartości bezpośrednio do pola current
(co najpierw przenosi dane z current
do previous
), ładuję je do akumulatora. W ten sposób usuwane są z kolejki wszystkie nieaktualne w danej klatce wartości drążka, a zostaje tylko ostatnia z kolejki zdarzeń, czyli ta ”najaktualniejsza”. Po przetworzeniu wszystkich zdarzeń z kolejki, dane z akumulatora używane są do aktualizacji stanu osi (current
do previous
, a następnie accumulator
do current
).
Od teraz świeże aktywowanie akcji typu włącz/wyłącz za pomocą gałki analogowej jest zawsze poprawnie wykrywane, bez względu na to ile zdarzeń ruchu gałki zostało w danej klatce wygenerowanych. Jest to niezwykle istotne, dlatego że to uniezależnia poprawność działania od framerate'u, jak i od lagów.
@furious programming: Próbowałeś te nagłówki ogarniać z pomocą chatgpt (albo innego llma). Wydaje się, że powinien sobie dobrze poradzić