Podstawy grafiki komputerowej

Na tym kursie zajmiemy się grafiką komputerową, jak się ją tworzy oraz jak ona działa. Napiszemy również dość sporo programów, aby móc z nią eksperymentować i lepiej zrozumieć niektóre zjawiska.

Zwyczajowo nową dziedzinę opisuje się podając jej rys historyczny. Z grafiką komputerową jest jednak pewien problem. Jej formy były (i są) tak bardzo różne, że trudno jest sklasyfikować dokładnie co nią jest. Mimo wszystko spróbuję podać przynajmniej zarys.

Wszystko zaczyna się w okolicach lata 50 XX-wieku. Już najwcześniejsze komputery potrafiły podawać wyniki graficznie w postaci wykresów oscyloskopowych. Do wczesnych form grafiki komputerowej można też zaliczyć obrazy tworzone na drukarkach przez umieszczanie liter w odpowiednich miejscach wydruku (ASCII-art) lub odpowiednio wyświetlając znaki i ustawiająch ich kolory i inne atrybuty na ekranie (ANSI-art).

Grafika wektorowa wyświetlana na specjalnym terminalu graficznym Tektronix 4052 (po lewej), rysunek kota wykonany w ANSI-art

Późniejsze komputery mogły wyświetlać, oprócz znaków alfanumerycznych, tzw. semigrafikę - obrazy tworzone z powtarzalnych kształtów wyświetlanych w taki sam sposób jak znaki. (Przykładem takiej grafiki jest, jeśli jeszcze ją pamiętacie, Telegazeta, czyli teletekst, albo grafika komputera Commodore 64 - PETSCII)

Zestaw znaków PETSCII oraz przykładowa grafika

Zdecydowanie można powiedzieć, że zawsze ograniczana była ona przez fizyczne możliwości komputera. To jest prawda również w przypadku współczesnej grafiki komputerowej - w dużej mierze składa się ona ze sprytnych optymalizacji oraz dobrze wykorzystanych iluzji.

Na tym kursie będziemy zajmowac się grafiką komputerową na współczesnych komputerach, zatem taką, złożoną z pikseli w pełnej głębi kolorów. Będziemy posiłkować się biblioteką pygame aby skupić się na tworzeniu samej grafiki. Bibilioteka pygame (wraz z systemem operacyjnym, sterownikami itp) chowa wewnętrzne działanie komputera, dzięki czemu ten sam program będzie działał na każdym komputerze tak samo. W ten sposób możemy skupic się na manipulowaniu pikselami, a nie programowaniem konkretnych podzespołów komputera.

Jak komputer wyświetla obraz?

Współczesne komputery wyświetlają obraz na ekranach (dziś najczęściej są to ekrany LCD lub OLED) złożonych z pikseli. Ekran jest sterowany przez specjalizowany układ - kartę graficzną, która komunikuje się z procesorem i resztą systemu przez szynę danych.

Aby wyświetlić coś na ekranie, komputer musi umieścić w odpowiednim miejscu pamięci dane, które reprezentują docelowy obraz. Następnie karta graficzna przetwarza je i generuje odpowiedni sygnał, który jest przesyłany do ekranu (monitora) podłączonego do komputera.

Ponieważ ekran składa się z pikseli, najsensowniejszym formatem jest przekazanie kolejnych wartości opisujących jasność i kolor kolejnych pikseli. Każdy piksel składa się z trzech sub-pikseli - czerwonego (R), zielonego (G) oraz niebieskiego (B) - i odpowiednio sterując ich jasnością, możemy zaświecać piksel na różne kolory. W tym przypadku mamy do czynienia z addytywnym mieszaniem kolorów, przykładowo, zaświecenie czerwonego sub-piksela na 100%, zielonego na 50% i zgaszenie niebieskiego spowoduje, że zobaczymy kolor pomarańczowy.

Współrzędne na ekranie i reprezentacja koloru

Piksele na ekranie są ułożone (najczęściej) w prostokątną macierz. Dzięki temu możemy opisać pozycje każdego z nich w układzie kartezjańskim, gdzie jedna jednostka odpowiada wysokości i szerokości każdego z pikseli. W takim układzie traktujemy ekran jako dwuwymiarową macierz komórek.

Zobaczmy jak to działa w praktyce tworząc prosty program w Pythonie, korzystając z PyGame. Przy okazji przygitujemy sobie szablon programu do naszych eksperymentów:

#!/usr/bin/env python3
import pygame as pg

S_WIDTH = 400
S_HEIGHT = 300

# Inicjujemy bibliotekę
pg.init()
pg.display.init()

# Tworzymy obiekt ekranu
screen = pg.display.set_mode((S_WIDTH, S_HEIGHT), 0, vsync=True)

# Wypełniamy tło na czarno
screen.fill([0, 0, 0])
# Potrzebujemy obiekt, którym dostajemy się do pojedynczych pikseli
pixels = pg.PixelArray(pg.display.get_surface())

# Zapalmy piksel na 100, 100 na biały
pixels[100, 100] = (255, 255, 255)

# Informujemy bibliotekę, że trzeba odświeżyć obraz
pg.display.flip()  

running = True
while running:
    # Tutaj będzie dziać się nasza gra
    
    events = pg.event.get()
    for event in events:
        if event.type in [pg.QUIT, pg.KEYDOWN]:
            running = False
    
pg.quit()

Powyższy program robi kilka rzeczy, które są niezbędne do wyświetlenia okienka. Większość z nich jest „schowana” za abstrakcjami, które dostarcza pygame. W pierwszych linijkach importujemy bibliotekę i definiujemy dwie stałe opisujące rozmiar ekranu (okienka gry). Następnie inicjujemy bibliotekę (powoduje to uruchomienie wewnętrznych funkcji bibliotecznych i kończy się wyświetleniem okienka na ekranie). Następnie tworzymy obiekt pixels który reprezentuje macierz pikseli znajdującą się w okienku. Zmiany tej macierzy docelowo spowodują zmiany w obrazie wyświetlanym w okienku gry.

W dalszej części dokonujemy tych zmian - ustawiamy piksel o indeksie (100, 100) na trójkę (255, 255, 255) reprezentującą kolor biały. Następnie informujemy bibliotekę, że należy odświeżyć ekran - to powoduje wywołanie wielu skomplikowanych procedur systemu operacyjnego które na końcu skutują skopiowaniem naszej macierzy pikseli do pamięci kompozytora pulpitu, następnie do pamięci karty graficznej i na końcu wyświetleniem go na ekranie komputera, na którym uruchamiasz program. Niestety, to co dzieje się dokładnie w tym momencie, jest bardzo skomplikowanym procesem i zdecydowanie wykracza poza program naszego kursu.

Pętla while jest główną pętlą „gry”. Ponieważ wszystko, co planowaliśmy zrobic w tym programie, zrobiliśmy w trakcie inicjalizacji, to na razie nie robi ona niczego, poza zapytaniem biblioteki czy nastąpiły jakieś wydarzenia (np ruszenie kursorem myszy, czy naciśnięcie klawisza). Jej jedynym działaniem jest zakończenie się w momencie, gdy uderzymy w klawiaturę albo naciśniemy przycisk zamknięcia okienka. Ostatnia funkcja pg.quit() powoduje posprzątanie wewnętrznych procedur i danych biblioteki oraz zakończenie programu.

Uff! Z powyższego opisu wydawać by się mogło, że wyświetlenie białej kropki na ekranie, to bardzo skomplikowana sprawa. Niestety to prawda, bo współczesne komputery są bardzo skomplikowanymi urządzeniami, zarówno pod względem sprzętowym jak i oprogramowania. Na szczęście dzięki abstrakcjom programistycznym, jakie dostarcza nam pygame oraz sam Python, możemy na chwilę o tym wszystkim zapomnieć, i ten sam program uruchomić na każdym komputerze, który jest wspierany przez Pythona oraz pygame, niezależnie od tego z jakich podzespołów się składa i jaki ma system operacyjny! Wszystkie różnice w tym, co trzeba zrobić, aby wyświetlić obraz na ekranie, są obsługiwane przez pygame oraz system operacyjny komputera. To bardzo upraszcza sprawy!

W poprzednim przykładzie pominęliśmy kwestie kolorowania piksela. Jak podaliśmy wyżej, każdy z pikseli składa się z trzech subpikseli w kolorach podstawowych RGB. Możemy zmieniać wartość każdego z nich przekazując inną liczbę w trójce, którą wpisujemy do macierzy pikseli. Poniższy przykład zapala kilka kolejnych pikseli na inne kolory. Aby zmienić nasz program, wystarczy że dopiszemy kilka poleceń zanim wywołamy funkcję odświeżenia ekranu gry (czyli pg.display.flip():

pixels[100, 200] = (255, 0, 255)  # Różowy
pixels[200, 100] = (0, 0, 255)    # Niebieski
pixels[100, 250] = (255, 255, 0)  # Żółty
pixels[150, 250] = (0, 255, 0)    # Zielony
pixels[250, 100] = (255, 0, 0)    # Czerwony

Po uruchomieniu tego przykładu w okienku gry powinne pojawić się kolejne kolorowe kropki:

  • Zadanie 1: Zmien ten program tak aby narysować poziomą linię przez cały ekran w wybranym kolorze

Jak widać, pierwsza współrzędna jest współrzędną poziomą, a druga pionową. Punkt (0, 0) znajduje się w lewym górnym rogu, ale nie zawsze jest to regułą. W grafice komputerowej bardzo często używa się wielu układów odniesienia jednocześnie, i konwertuje między nimi, jak zobaczymy w kolejnych przykładach. Ogólnie rzecz biorąc, wszystkie nasze programy będą działać w podobny sposób - ich zadaniem jest obliczenie macierzy trójek RGB, która ma reprezentować wyświetlany obraz na ekranie, a następnie zareagowanie na dane wejściowe (ruch kursorem myszki, naciskanie klawiszy) użytkownika. Całą resztę pracy wykonuje za nas pygame.

Kolor jest reprezentowany przez trójkę niezależnych zmiennych - zwyczajowo każda z nich ma zakres od 0 (zgaszony) do 255 (zapalony), czyli 8 bitów. Razem mają one 24 bity, i \(2^{24}\) możliwych kombinacji - tak zwany 24-bitowy True Colour. Możemy traktować tę trójkę jako punkt w trójwymiarowej przestrzeni, i każdemu z punktów przypisać kolor wynikający z sumy jasności trzech podpikseli RGB - otrzymamy wtedy szcześcian, którego wierzchołki mają następujące kolory:

Na powyższym obrazku wartości kolorów mają zakres od 0 do 1. Jest to po prostu inna reprezentacja tych samych danych, stosowana aby wygodnie obsłużyć przypadki, gdzie ilość bitów na piksel nie wynosi 24. Aby przejść z niej do naszej 24-bitowej reprezentacji RGB, należy pomnożyć współrzędne przez 256.

Bitmapy

Jak dowiedzieliście się w poprzednim dziale, aby wyświetlić obraz na ekranie komputera musimy policzyć albo wygenerować macierz która opisuje jasności trzech kolorów podstawowych każdego piksela. Taką macierz można również stworzyć albo wczytać z pliku zawierającego obrazek, czyli tak zwanej bitmapy. Bitmapy przechowują obrazy grafiki rastrowej (złożonej z pikseli). W szczególności, cyfrowe zdjęcia i skany dokumentów są bitmapami.

W grafice komputerowej możemy przetwarzać bitmapy na dowolne sposoby. Możemy je umieszczać na ekranie, ruszać nimi, skalować, obracać, itp. Takie operacje wykonuje komputer gdy ruszasz wskaźnikiem myszy po ekranie - strzałeczka która zmienia swoje położenie, jest małą bitmapą którą kompozytor okien kopiuje w odpowiednie miejsce ekranu aby pokazać aktualne położenie wirtualnego kursora. W przypadku grafiki 3D możemy również przekstałcać prostokątne bitmapy w dowolne czworokąty aby zasymulować trójwymiarowy obraz.

Do załadowania obrazka do bitmapy posłuży nam funkcja pygame.image.load(). Następnie skopiujemy bitmapę w jakieś miejsce naszego okienka gry za pomocą funkcji screen.blit (skopiujemy jedną bitmapę w jakiś obszar drugiej). Zobaczmy to na poniższym przykładzie

# Plik: 2d_bitmap.py
bitmap = pg.image.load("logo.png")
bitmap_pixels = pg.PixelArray(bitmap) # Macierz pikseli bitmapy
# Nayrusjmy mały gradient
for i in range(10):
	bitmap_pixels[:, 20+i] = (25*i, 0, 25*i)
del bitmap_pixels

# Kopiujemy bitmapę na ekran
screen.blit(bitmap, (100, 100))

# Informujemy bibliotekę, że trzeba odświeżyć obraz
pg.display.flip()

Po naszych operacjach na pikselach musimy skasować obiekt bitmap_pixels, ponieważ tego wymaga funkcja blit.

Oto wynik działania naszego programu: na ekranie znajduje się bitmapa wczytana z pliku z obrazem, oraz narysowanym na niej gradientem.

  • Zadanie: Zmień położenie, rozmiar i kolor gradientu wg własnego uznania

Ciekawostka: słowo „blit” to skrótowiec od „Block Image Transfer”, ponieważ historycznie tę operację wykonywał specjalizowany podzespół komputera zwany blitterem. Blitter był układem elektronicznym, który służył do kopiowania „prostokątnych” obszarów pamięci i wykonywania na nich prostych operacji graficznych (maskowanie, przesuwanie, odbijanie), zwalniając tym samym procesor do przetwarzania innych danych.