Archive for Programming

MRT i techniki typu deferred

2008-03-16 21:42

Zdążyliśmy się już przyzwyczaić do tego, że w programowaniu grafiki bardzo często wykorzystujemy zastane mechanizmy w zupełnie inny sposób niż ten formalnie założony. I tak: tekstury nie muszą być tylko bitmapami nakładanymi na geometrię, ich rzekome współrzędne mogą tak naprawdę przechowywać pozycję 3D lub normalne, a piksele nie muszą wcale zawierać danych o kolorach.
Analogicznie potok renderowania nie musi też produkować pikseli do wyświetlenia na ekranie! Zamiast tego możliwe jest, aby dokonywał on najpierw wyliczania pewnych właściwości materiałów dla każdego piksela na powierzchni ekranu i to właśnie te informacje zapisywał jako wynikowy “kolor” w teksturze typu Render Target. Później byłyby one wykorzystywane do obliczeń związanych np. z oświetleniem i cieniowaniem sceny. Zaletą takiego podejścia (znanego powszechnie jako Deferred Shading) jest między innymi oszczędzenie sobie wielokrotnego przetwarzania wierzchołków przez vertex shader, np. wtedy, gdy potrzebnych jest wiele przebiegów dla wielu świateł. Z drugiej strony w ten sposób znacznie bardziej obciąża się jednostki Pixel Shader, których liczba w starszych karta jest stała i niezbyt duża. Korzyścią jest jednak uproszczenie całego procesu renderowania, nawet jeśli z powyższego opisu nieszczególnie to wynika ;-)

Pozostaje jeszcze pewien drobny szkopuł. Otóż materiały mają wiele parametrów, które często są złożone, i nie da się ich wszystkich upakować w pojedynczym pikselu, składającym się jedynie z czterech wartości zmiennoprzecinkowych. Dlatego też stosuje się tutaj “wielokrotne cele renderowania” (Multiple Render Targets – MRT), a więc produkowanie przez potok więcej niż jednej wartości koloru naraz. Zwykle po prostu każdy parametr materiału jest zapisywany osobno, do innej powierzchni typu Render Target. Rozróżnienie odbywa się na poziomie pixel shadera. Zamiast zwracać jedną wartość o semantyce COLOR0 (która domyślnie trafia do bufora ramki, czyli – w przybliżeniu – na ekran), może on wypluć także COLOR1, COLOR2 i tak dalej:

Pixel shader dla MRT

Rezultaty te chcielibyśmy rzecz jasna odebrać i zachować, ale w DirectX wystarcza do tego zwykła metoda SetRenderTarget, której podajemy powierzchnię działającą jako Render Target o danym indeksie. Są tutaj oczywiście pewne obostrzenia (łącznie z tym najbardziej oczywistym – rozmiaru powierzchni).
Największym mankamentem jest jednak to, że jedynie stosunkowo nowe karty (np. nVidii od serii 6) obsługują MRT. Można to sprawdzić, czytając wartość pola NumSimultaneousRTs struktury D3DCAPS, które da nam – niezbyt imponującą, bo wynoszącą zwykle 4 lub 8 – maksymalną liczbę Render Targets podpiętych jednocześnie. W tak niewielkiej ilości (zwłaszcza, jeśli równa jest ona nawet mniej, czyli… 1 :]) może być niełatwo zmieścić wszystkie potrzebne informacje o materiałach.

Ale w programowaniu grafiki zwykle bywa tak, że najlepiej jest wybierać techniki, które jeszcze wydają się nowe. Wtedy bowiem jest całkiem prawdopodobne, że w chwili kończenia projektu wsparcie dla nich będzie już powszechne. Zważywszy na to, że w moim przypadku ciężko jest powiedzieć, kiedy pisanie właściwego potoku renderowania zdołam chociaż zacząć – o zakończeniu nie wspominając – techniki typu deferred zdają się być całkiem rozsądnym wyborem do rozważenia :)

Bity bez znaku

2008-03-14 22:58

Chociaż programowanie staje się coraz bardziej wysokopoziomowe, zdarza się, że trzeba zejść na niziny i wykonać kilka “staromodnych” operacji’. Dotyczy to na przykład działań na bitach, co jest zazwyczaj związane jest z koniecznością interpretacji różnych sposobów reprezentowania danych. Bardzo dobrym przykładem jest chociażby blokowanie i odczyt/zapis powierzchni (tekstur, bufora ramki, głębokości, itd.) w bibliotekach graficznych. Format pikseli może być bowiem taki, że dostanie się do informacji o kolorze będzie wtedy wymagało nieco zachodu, w tym także operacji na pojedynczych bitach.

Ale nie tym będę się zajmował, gdyż, jak wiadomo, jest to przecież proste ;-) Dzisiaj chciałem zwrócić uwagę na pewien niezwykle ważny szczegół związany z operacjami bitowymi, mogący powodować powstawanie błędów, które na pierwszy rzut oka wyglądają cokolwiek tajemniczo. Chodzi mianowicie o różnice między typami liczbowymi ze znakiem i bez znaku, kiedy chodzi właśnie o działania z użyciem operatorów bitowych.
Ogólna zasada brzmi, aby – jeśli to tylko możliwe – korzystać wtedy wyłącznie z typów liczbowych bez znaku. Mogłoby się wydawać, że to żadna różnica, skoro i tak nie interesują nas wartości zmiennych w interpretacji liczbowej, a jedynie “gołe” bity. Rzecz w tym, że dla kompilatora takie rozróżnienie zazwyczaj nie istnieje i poza nielicznymi wyjątkami (jak std::bitset w C++) należy stosować zmienne typu liczbowego, gdy operujemy na bitach.
Istnieją zaś przypadki, kiedy uznawanie jakiegoś ciągu bitów za liczbę ze znakiem powoduje zupełnie inne efekty niż interpretacja tego samego ciągu jako liczby bez znaku. Naturalnie cały czas mówimy o korzystaniu jedynie z operatorów bitowych! Co to za szczególne sytuacje?…

  • Jedna z nich dotyczy automatycznego rzutowania typów mniejszych na większe. Jeśli mamy krótki ciąg bitów, zapisany np. w zmiennej o rozmiarze 1 bajta, i chcemy go przepisać do zmiennej o większym rozmiarze, to cała operacja jest w miarę oczywista dla typów bez znaku. W nowym słowie stare bity zostaną umieszczone na najmniej znaczących pozycjach, zaś reszta zostanie wypełniona zerami.
    Natomiast dla typów ze znakiem przepisywanie dotyczyć będzie liczby reprezentowanej przez rzeczone bity, a nie ich samych. Jeśli więc mamy np. ciąg ośmiu jedynek, to przy standardowym sposobie zapisywania liczb całkowitych oznacza on po prostu -1. Po rozszerzeniu wartości -1 na 32 bity otrzymamy znów ciąg samych jedynek – lecz tym razem aż trzydziestu dwóch, bo taka jest 32-bitowa reprezentacja liczby -1. Dla typów bez znaku otrzymalibyśmy naturalnie ciąg 24 zer i 8 jedynek.
  • Druga uwaga odnosi się do operacji przesunięcia bitowego w prawo, które może mieć dwa warianty. Nazywają się one… przesunięciem ze znakiem i bez znaku, zaś różnica między nimi polega na tym, czym są wypełniane wolne miejsca pojawiające się z lewej strony przesuwanego słowa. W pierwszym przypadku jest to najbardziej znaczący bit (zwany często bitem znaku dla typów ze znakiem, nawet jeśli faktycznie nim nie jest). W drugim zaś lewa strona jest wypełniana po prostu zerami.
    Kłopot w tym, że nie zawsze wiadomo, którego rodzaju przesunięcia akurat użyjemy. W C++ na przykład zasadniczo zależy to od typu argumentów operatora >>, lecz z drugiej strony wcale nie jest powiedziane, że zawsze stosowany jest wariant bez znaku dla typów signed i bez znaku dla unsigned. Jest to zależne od kompilatora, choć rzecz jasna większość zachowuje się w tym względzie odpowiednio rozsądnie.

Tym niemniej nie powinno się na takiej dobroci polegać i należy, do celów działania na bitach, używać typów bez znaku. Inna sprawa, że niektóre języki programowania takich typów w ogóle nie mają i wtedy możemy napotkać (nie)wielki problem. Ale to już całkowicie odrębny temat do narzekania i poruszę go może przy innej okazji :)

Tags: ,
Author: Xion, posted under Programming » 3 comments

Tajemnice stałych referencji

2008-03-13 21:47

Zapewne wielu programistów C++ zetknęło się z taką lub podobną sytuacją. Oto w pocie czoła napisana funkcja w postaci podobnej do tej:

  1. void Foo(string s) { /* ... */ }

zostaje obejrzana przez bardziej doświadczonego kodera, który stwierdza: “Powinieneś użyć tutaj stałej referencji jako parametru – czyli napisać:

  1. void Foo(const string& s)

Unikniesz wtedy niepotrzebnego kopiowania stringa”. Wówczas możemy zrobić wielkie oczy i zdziwić się bardzo, zwłaszcza jeśli wiemy co nieco o referencjach w C++. Mimo to sugestia ta jest jak najbardziej na miejscu i powinniśmy się do niej stosować. Wstyd przyznać się, że przez dłuższy czas niesłusznie brałem ją tylko na wiarę, lecz na szczęście jakiś czas temu dowiedziałem się dokładnie, o co tutaj chodzi. Tą cenną wiedzą oczywiście się podzielę :)

Wiadomo, że w C++ referencje to w zasadzie to samo, co (raczej rzadko używane) stałe wskaźniki – czyli zmienne w typu T* const. Różnią się one aczkolwiek składnią: wszystkie dereferencje są dokonywane przezroczyście i kod wygląda tak, jakbyśmy posługiwali się zwykła zmienną docelowego typu, na który referencja wskazuje (czyli T). Podobnie stałe referencje są odpowiednikami stałych wskaźników na stałe (const T* const), czyli takich, które nie pozwalają ani na modyfikację obiektu wskazywanego, ani na zmianę samego wskazania.
W każdym przypadku wskaźniki muszą jednak na coś pokazywać; na coś, co ma określone miejsce w pamięci, czyli adres. W zasadzie podobna reguła dotyczy też referencji – z jednym małym, ale jakże ważnym wyjątkiem.

Otóż powyższą funkcję (w wersji ze stałą referencją jako parametrem) możemy bez problemów wywołać tak, jak poniżej:

  1. Foo ("Hello world");

Niby nic nadzwyczajnego, ale zauważmy, że tworzony jest tutaj tymczasowy obiekt string, na który następnie pokazuje referencja w ciele naszej funkcji. Błąd standardu lub kompilatora? Wręcz przeciwnie – it’s not a bug, it’s a feature :)
Po prostu w C++ stałe (i tylko stałe) referencje mogą poprawnie wskazywać na obiekty tymczasowe. Życie takich obiektów jest wówczas przedłużane aż do czasu wyjścia poza zasięg istnienia referencji. W naszym przypadku utworzony tymczasowy obiekt string będzie więc dostępny w całej funkcji.

Biorąc pod uwagę fakt, że zamiana deklaracji string s na const string& s nie kosztuje nas nic (wewnątrz funkcji do parametru odwołujemy się tak samo), możemy zerowym kosztem zyskać sporą optymalizację. Przekazanie referencji kosztuje przecież tyle samo co przekazanie wskaźnika i na pewno jest nieporównywalnie tańsze niż wykonywanie kopii całego napisu.
Dlatego też nie tylko w przypadku klasy string, ale i we wszystkich podobnych sytuacjach obiekty powinniśmy przekazywać właśnie przez stałe referencje.

Tags: , ,
Author: Xion, posted under Programming » 4 comments

Asynchroniczność kontra wątki

2008-03-10 23:02

Niekiedy trzeba zrobić coś czasochłonnego: operację, która nie zakończy się od razu, lecz zabierze zauważalny odcinek czasu. Dość często dotyczy to odczytu (lub zapisu) danych z miejsca, które nie musi być natychmiast dostępne: gniazdka sieciowego, międzyprocesowego potoku (pipe) czy w niektórych sytuacjach nawet pamięci dyskowej. Wówczas rzadko możemy pozwolić sobie na “powieszenie” programu na parę(naście/dziesiąt) sekund w oczekiwaniu, aż zlecona operacja się zakończy. W międzyczasie trzeba bowiem wykonywać też inne czynności, z aktualizacją interfejsu użytkownika na czele.

Typowy rozwiązaniem jest wtedy umieszczenie czasochłonnej czynności w osobnym wątku. Zdarza się jednak, że nie jest to jedyne wyjście. Niekiedy – na przykład przy korzystaniu z gniazd sieciowych w Windows API lub dowolnych strumieni w .NET – dysponujemy alternatywnym sposobem, którym jest zlecenie operacji asynchronicznej. Polega ono na żądaniu wykonania danego działania “w tle” wraz ze sposobem, w jaki chcemy odebrać informację zwrotną. W tym charakterze chyba najczęściej stosuje się funkcje typu callback, podawane – zależnie od języka – jako wskaźniki (C/C++), delegaci (Delphi, C#) lub obiekty implementujące ustalone interfejsy (Java). Po zakolejkowaniu takiego żądania program wykonuje się dalej bez żadnych przerw. Gdy zaś operacja zakończy się, nasz callback zostanie wywołany i w nim będzie można pobrać rezultaty zleconego zadania.
Brzmi całkiem nieźle, prawda? Właściwie można by powiedzieć, że to świetny sposób na uniknięcie stosowania tych strasznych wątków ;-) W praktyce trzeba jednak pamiętać o tym, że:

  • Brak wątków wcale nie oznacza, że nie wystąpią typowe dla wielowątkowości kłopoty – zwłaszcza problemy z synchronizacją dostępu do zasobów. Dotyczą one bowiem każdej sytuacji, kiedy to kod wykonujący się równoległe próbuje uzyskać dostęp do tych samych danych – niezależnie od tego, czy dotyczy to wątków, osobnych procesów ze współdzieloną pamięcią czy właśnie operacji asynchronicznych. Zresztą wewnątrz mechanizmu obsługi takich operacji równie dobrze siedzieć może zwyczajny wątek, który się nimi zajmuje w sposób przezroczysty.
  • Wywołania asynchroniczne “rozgałęziają” wykonywanie kodu w sposób bardziej subtelny (co oznacza: trudniejszy do śledzenia) niż wątki. W przypadku wątków zawsze dobrze widać miejsce rozpoczęcia i zakończenia pracy oraz całą te robotę, napisaną po kolei i w jednym miejscu. Jeśli zaś korzystamy z wywołań asynchronicznych, to kod obsługi wyniku operacji jest oddzielony od kodu, który tę operację zleca. To sprawia, że śledzenie i debugowanie może być trudniejsze i wymagać większej uwagi.

W sumie więc warto pamiętać o tym, że przy wprowadzaniu równoległości trzeba zawsze liczyć z dodatkowymi – nazwijmy to – “kwestiami do rozważenia” :] Unikanie tworzenia wątków za wszelką cenę nie musi zatem być najlepszym wyjściem, skoro koszt rozwiązania alternatywnego bywa podobny.

Tags: ,
Author: Xion, posted under Programming » 5 comments

Procent od funkcji drukujących

2008-03-08 23:58

Wszyscy znamy funkcję printf – część standardowej biblioteki C – oraz jej liczne warianty z przedrostkami i przyrostkami, służące wypisywaniu tekstów do różnych miejsc na różne sposoby. Jeśli z nich korzystamy, to czasem zdarza się, że chcemy wydrukować komunikat dany jako pojedynczy, znany już napis. A wówczas można wyprodukować coś, co w najprostszej wersji będzie wyglądało tak:

  1. char* s;
  2. // ...
  3. printf (s);

Tak się jednak stringów nie wypisuje – nawet mimo tego, iż w większości przypadków działa to bez problemów. Możemy bowiem trafić na przypadek złośliwy, a błędy objawiające się tylko czasami są, jak wiemy, jednymi z najgorszych…

Rzecz w tym, że w rodzinie funkcji printfopodobnych za to, co wydrukujemy, odpowiadają dwie rzeczy. Drugą z nich jest lista danych, mogąca mieć dowolnie dużo elementów; stąd też funkcje te przyjmują zmienną liczbę argumentów. Ale pierwszą jest tak zwany format, który mówi, jak te elementy należy interpretować: jako liczby całkowite, zmiennoprzecinkowe czy w końcu napisy. Ten argument jest łańcuchem znaków, występuje przed pozostałymi i w odróżnieniu od nich jest obowiązkowy.
Wywołanie printf(s); w istocie oznacza więc, że s nie jest tekstem do wypisania, ale formatem służącym interpretacji ewentualnych dalszych parametrów. Skoro jednak kolejnych argumentów nie ma, to wydaje się, że nie ma też problemu – zwłaszcza, że w wyniku tekst spod s faktycznie jest wypisywany. Jest tak jednak tylko momentu, gdy natrafimy na łańcuch zawierający znaczek procenta (%).

Jak wiemy, format dla funkcji typu printf może bowiem zawierać (i zwykle zawiera) znaczniki odnoszące się do jej dalszych argumentów. Niemal zawsze wpisujemy je ręcznie w kodzie, bo dokładnie wiemy, co chcemy wydrukować – np.:

  1. int n;
  2. printf ("n = %i", n);

To sprawia, że bardzo łatwo zacząć je traktować identycznie jak sekwencje ucieczki, czyli podciągi \n, \t, itd., zamieniane przez kompilator na odpowiadające im znaki (tutaj: końca wiersza i tabulatora). W wynikowych stringach nie ma więc po nich śladu, zamiast tego są odpowiednie znaki, których nie da się normalnie wpisać z klawiatury.
Ale znaczniki formatujące nie są interpretowane przez kompilator. Podczas działania programu w tym łańcuchu nadal siedzi %d, %f i każdy inny znacznik rozpoczynający się od procenta. Jeśli więc łańcuch s z wywołania printf(s); przypadkiem zawiera znak procenta, to funkcja mylnie potraktuje go i znaki po nim występujące jako znacznik formatujący. Zachowanie może być wtedy różne – w najlepszym wypadku ów procent i następny znak zostaną po prostu “zjedzone” – ale zawsze będzie różne od naszych oczekiwań.

Konkluzja? Jest oczywiście taka, aby zawsze pamiętać o formacie i nawet jeśli wypisujemy “tylko” łańcuch znaków, umieścić w nim %s:

  1. printf ("%s", s);

Różnica mała, lecz ważna :)

Tags: ,
Author: Xion, posted under Programming » 3 comments

Wskaźnik to tylko adres

2008-03-06 21:01

Jak C++ długi i szeroki, wskaźniki zawsze sprawiają początkującym (i nawet tym nieco bardziej zaawansowanym) programistom pewne kłopoty. Sam widziałem to zdecydowanie za dużo razy :) Niby wszyscy wiedzą, że wskaźnik to takie coś, co – jak podpowiada sama nazwa – pokazuje na jakiś inny obiekt. Czyli jest to taka “zmienna, która pokazuje na inną zmienną”.
Przy tej interpretacji nie jest trudno zorientować się, że przekazując wskaźnik chociażby jako argument funkcji, pozwalamy jej modyfikować obiekt, na który ów wskaźnik pokazuje – bez kopiowania tego obiektu. Bardzo podobnie działają zresztą referencje w językach typu C#, Delphi, Java, itp. Dlatego też zdawałoby się, że można by je z powodzeniem utożsamiać ze wskaźnikami w C/C++.

Uważam, że nic bardziej mylnego! Pomijam już taki drobiazg, że nad wspomnianymi referencjami czuwa odśmiecacz pamięci, który zapobiega wyciekom, podczas gdy wskaźników nic takiego nie dotyczy. Różnica jest bowiem znacznie głębsza. Te referencje są trochę “magiczne” – w tym sensie, że, ściśle mówiąc, właściwie nie wiadomo, jak fizycznie one działają (albo raczej: nie ma potrzeby, aby to wiedzieć). Grunt, że pokazują na jakiś obiekt, zaś obiekt ten może być wskazywany przez wiele referencji, a odwoływanie się do przez którąkolwiek z nich jest całkowicie równoważne.
W C/C++ w przypadku zwykłych wskaźników zasadniczo jest tak samo (z dokładnością do arytmetyki). Kłopoty zazwyczaj zaczynają się wtedy, gdy zostajemy uraczeni dwiema gwiazdkami i otrzymujemy wskaźnik na wskaźnik. Odpowiadająca mu “referencja do referencji” w C#, itp. jest bowiem czymś zupełnie bez sensu, jako że referencja nie jest przecież obiektem, na który można by pokazywać. I wówczas cała interpretacja wskaźników jako “czegoś, co w jakiś sposób pokazuje na coś” staje się co najmniej zastanawiająca.

A przecież dokładnie wiadomo, w jaki sposób wskaźniki ‘pokazują’. Przecież wskaźnik to nic innego, jak zmienna, która zawiera adres pewnego miejsca w pamięci. Zwykle adres ten odnosi się do innej zmiennej, obiektu, czasem funkcji, itd. I tyle, nie ma tutaj żadnego nadprzyrodzonego połączenia między wskaźnikiem a obiektem wskazywanym.
Ważne jest więc, by uświadomić sobie, że wskaźnik to zwykła zmienna zawierająca po prostu jakąś wartość (tutaj jest to pewien adres). To zaś oznacza, że sama ta zmienna również posiada jakieś miejsce w pamięci, czyli też rezyduje pod jakimś adresem. Kiedy pójdziemy za tym tokiem rozumowania, nie ma większych problemów z interpretacją podwójnych, potrójnych i wielokrotnych wskaźników.
A więc żadnej magii – to tylko liczby. Nie ma się czego bać :)

Tags: ,
Author: Xion, posted under Programming » 6 comments

Systemy kontroli wersji

2008-03-04 23:04

Kiedy nad jednym projektem pracuje więcej niż jedna osoba, nieuchronnie powstaną problemu z synchronizacją kodu napisanego przez różnych programistów. Jeśli wersje utrzymywane i modyfikowane przez poszczególne osoby będą przez jakiś (nawet całkiem krótki) czas odizolowane od siebie, wtedy nieuchronnie się “rozjadą”. Integracja takich kawałków będzie potem bardzo trudna.
Takich problemów nie da się rzecz jasna całkiem uniknąć, ale wymyślono kilka sposobów, które pomagają je rozwiązywać. Wśród nich pewnie najważniejsze są systemy kontroli wersji.

Logo TortoiseCVSIdea działania takiego systemu jest w miarę prosta. Istnieje mianowicie centralne i ogólnodostępne (czytaj: umieszczone na zdalnym serwerze) miejsce, gdzie składowane są różne wersje kodu projektu – czyli repozytorium. Pracujące nad nim osoby pobierają z niego aktualną wersję, dokonują swoich modyfikacji, a następnie załadowują ją z powrotem (tzw. commit), tworząc w ten sposób nową wersję w repozytorium. Po drodze mogą oczywiście wyniknąć konflikty, jeśli ten sam kod jest zmieniany równocześnie przez dwóch użytkowników; takie niezgodności są wykrywane automatycznie, ale ich rozwiązywanie należy już do programistów. Ponieważ jednak w repozytorium trzymana jest historia wszelkich zmian, zawsze można powrócić do poprzedniej wersji, jeśli coś pójdzie nie tak.
Logo SVNNajpopularniejszym systemem kontroli wersji jest oczywiście CVS (Concurrent Version System), o którym słyszał pewnie każdy. Pośród licznych jego wad największą jest chyba ta, iż jest… wciąż bardzo popularny :) Jest tak mimo tego, że od prawie dekady istnieje znacznie lepszy system o nazwie Subversion (w skrócie SVN), który ma liczne przewagi nad swoim poprzednikiem. Jak choćby to, że potrafi też wersjonować zmiany w strukturze katalogów, lepiej obsługiwać pliki binarne i nie grozić zepsuciem repozytorium, jeśli podczas commitu zdarzy się coś złego z serwerem lub połączeniem.

Jak to często bywa, przyczyną takiego stanu rzeczy jest naturalnie zasiedzenie tudzież przyzwyczajenie. Skoro bowiem tacy giganci jak SourceForge nadal opierają się na CVS-ie, prawdopodobnie jeszcze przez długi czas trzeba będzie radzić sobie z jego niedogodnościami. Jakby sama praca grupowa nie dostarczała wystarczającej liczby kłopotów… ;P

Tags:
Author: Xion, posted under Programming » 4 comments
 


© 2022 Karol Kuczmarski "Xion". Layout by Urszulka. Powered by WordPress with QuickLaTeX.com.