Rysowanie kształtów

Jak dotąd wsyzstkie nasze „gry” sprowadzały się do narysowania czegoś na ekranie, a potem oczekiwaniu na zamknięcie programu przez uzytkownika. Teraz postaramy się narysować kilka prostych kształtów, a następnie zaprogramować prostą animację.

W poprzednich ćwiczeniach rysowaliśmy pojedyncze kolorowe piksele na ekranie. Używaliśmy też pętli, aby rysować proste gradienty. W tym przykładzie rozbudujemy tę metodę aby narysować kilka prostych kształtów - kwadrat, prostokąt oraz okręgi.

Przypomnienie poprzednich zajęć

  • Piksele na ekranie mogą być traktowane jako dwuwymiarowy układ współrzędnych kartezjańskich

  • w Pygame punkt \((0, 0)\) znajduje się w lewym górnym rogu ekranu, a osie są skierowane w prawo i w dół

  • Każdy piksel można opisać trójką \((R, G, B)\), która opisuje jego kolor.

  • Wartości w trójce są liczbami całkowitymi w zakresie \(\langle0, 255\rangle\), czyli 8-bitowymi liczbami całkowitymi.

  • Trójkę RGB można traktować jako punkt w trójwymiarowej przestrzeni kolorów.

  • Jest to jedna z wielu możliwych reprezentacji koloru używana w grafice komputerowej

  • Reprezentacji RGB używamy ponieważ odpowiada ona bezpośrednio jasności pikseli na ekranie, mówimy że jest to natywna reprezentacja.

Teraz zajmiemy się rysowaniem różnych kształtów na ekranie przez ustawianie kolorów odpowiednich pikseli w macierzy.

Kwadrat i prostokąt

Zacznijmy od kwadratów. Aby narysować kwadrat, wystarczy zapełnić kwadratowy obszar pikseli jednym kolorem

pixels = pg.PixelArray(pg.display.get_surface())

# Narysujmy czerwony kwadrat na pozycji 100, 200 o boku 50
for x in range(50):
	for y in range(50):
		pixels[100 + x, 200 + y] = (255, 0, 0)

Rysowanie prostokąta również nie jest trudne. Wystarczy zmienić zakres jednej z pętli

# Narysujmy błękitny prostokąt
for x in range(50):
	for y in range(70):
		pixels[200 + x, 250 + y] = (128, 128, 255)
  • Zadanie: zwróć uwagę jakie współrzędne macierzy pikseli są zmieniane. Czym jest punkt \((200, 250)\)?

Koła i okręgi

Rysowanie koła i okręgów wymaga od nas znajomości wzoru na koło. Chcemy zakolorowac wszystkie piksele które spełniają nierówność

\[x^2 + y^2 < r^2\]

Aby to zrobić, musimy sprawdzić które piksele spełniają to równanie, i odpowiedno je pokolorować.

# Trudniejsze - koło
circ_x, circ_y, circ_r = 200, 100, 50

for x in range(-50, 50):
	for y in range(-50, 50):
		if( x**2 + y**2 < circ_r**2 ):
			pixels[x + circ_x, y + circ_y] = (0, 255, 0) # punkt w okregu

Zastanówmy się chwilę nad tym programem. W brew pozorom dzieje sie tu dość dużo nowych rzeczy. Po pierwsze, szkielet programu wygląda tak samo jak rysowanie kwadratu. Dołożyliśmy jednak warunek który powoduje, że nie wszystkie piksele są pokolorowane. Zatem aby narysowac koło, i tak musimy przetworzyć obszar który jest prostokątny (kwadratowy).

Narysowanie okręgu jest również proste. Musimy tylko zmienić naszą nierówność na równość? Tu pojawia się problem. Okrąg ma zerową grubość, więc nie zobaczymy go na ekranie. Najlepsze co można z tym zrobić, to narysować pierścień.

\[\begin{split}\begin{aligned} x^2 + y^2 &= r^2 \quad \text{Równanie okręgu} \\ | x^2 + y^2 - r^2 | &< t^2 \quad \text{Nierówność pierścienia o grubości 2t} \end{aligned}\end{split}\]
# Pierścień (gruby okrąg)
circ_x, circ_y, circ_r = 350, 100, 60
for x in range(-75, 75):
	for y in range(-75, 75):
		if( abs(x**2 + y**2 - circ_r**2) < 200):
			pixels[x + circ_x, y + circ_y] = (0, 255, 255) # punkt w okregu

Zwróćmy jeszcze uwagę na to jakie zmienne podstawiamy do równania i nierówności, a jakie piksele zmieniamy w macierzy. W równaniu korzystamy ze zmiennych x oraz y. Natomiast w macierzy pikseli zmieniamy indeksy x + circ_x oraz y + circ_y. Możemy to traktować jako dwa różne układy współrzędnych - jeden należący do okienka, a drugi do samego okręgu. Obliczając, sprawdzamy warunek w układzie współrzędnych w którym środek okręgu znajduje się w (0, 0), ale gdy zapełniamy macierz pikseli, korzystamy z układu przesuniętego o (circ_x, circ_y) pikseli w prawo i w dół. To jeden z wielu przypadków gdzie posiłkujemy się wieloma układami współrzędnych. Obliczanie okręgu w układzie współrzędnych okienka było by dużo mniej wygodne!

Dla dobrego humoru narysujmy sobie jeszcze gradient na dole ekranu:

for y in range(255):
    r, g, b = y, y, y
    pixels[:, S_HEIGHT-255 + y] = (r, g, b)

Wynik programu rysującego (prawie) wszystkie kształty.

Rysowanie trójkątów

Sprawa jest dość skomplikowana. Jest kilka sposobów na narysowanie trójkąta, lecz większość z nich wymaga znajomości prostych ograniczających jego boki. Zastanówmy się, w jaki sposób opisac trójkąt w kartezjańskim układzie współrzędnych? Możemy traktować go jako płaszczyznę ograniczoną trzema prostymi, które parami przecinają się w trzech wierzchołkach trójkąta.

Wynika z tego, że nasze dane wejściowe będą zawierać wierzchołki, a zadaniem programu będzie obliczyć równania prostych, które będą zawierały boki trójkąta.

Weźmy nasze wierzchołki wejściowe jako \((x_0, y_0)\), \((x_1, y_1)\), \((x_2, y_2)\) (numeracja od 0, bo to pozwoli nam przepisac indeksy na indeksy list w pythonie). Mamy również trzy proste \(k_1, k_2, k_3\) które przez nie przechodzą tak jak na rysunku poniżej. Układ kartezjański jest „do góry nogami”, bo taki układ ma docelowa macierz pikseli.

Prostą przechodzącą przez dwa punkty \((x_0, y_0), (x_1, y_1)\) możemy znaleźć rozwiązując układ równań, który otrzymamy przez podstawienie dwóch punktów do równania prostej \(y = ax + b\). Aby go rozwiazać, punkty muszą spełniać warunek \(x_0 \neq x_1\). Wtedy otrzymamy wyrażenie:

\[x(y_1 - y_0) + y(x_0 - x_1) -y_0(x_0 - x_1) + x_0(y_0 - y_1) = 0\]

Można pokazać, że w zadanych punktach równość jest prawdziwa. To wyrażenie ma formę równania ogólnego prostej, czyli \(Ax + By + C = 0\). Zauważ, że w tym wyrażeniu nie ma żadnego powodu aby \(x_0 \neq x_1\). To jedna z przydatnych właściwości równania ogólnego prostej - może opisać więcej prostych niż równanie kierunkowe (może opisać wszystkie możliwe proste na płaszczyźnie).

Trzy proste możemy opisać równaniami prostych

\[\begin{split}\begin{aligned} k_0: & \quad Ax + By + C = 0 \\ k_1: & \quad Dx + Ey + F = 0 \\ k_2: & \quad Gx + Hy + I = 0 \end{aligned}\end{split}\]

Równanie ogólne prostej ma również drugą przydatną właściwość - dzieli płaszczyznę na dwie półpłaszczyzny. Na jednej z nich wartość równania jest \(>0\), na drugiej \(<0\), na prostej jest równa zero. Aby sprawdzić po której stronie prostej leży dany punkt, wystarczy że podstawimy go do równania i sprawdzimy czy wynik jest mniejszy czy większy od zera.

Z równania prostej przecinającej dwa punkty można odczytać wartości współczynników \(A, B, C\):

\[\begin{split}\begin{aligned} A &= y_1 - y_0 \\ B &= x_0 - x_1 \\ C &= -y_0 (x_0 - x_1) + x_0 (y_0 - y_1) \end{aligned}\end{split}\]

Podobnie można znaleźć pozostałe proste - musimy „obrócić” punkty, czyli \(x_0\) staje się \(x_1\) itp. Aby znaleźć trzecią prostą, obracamy punkty dalej, mając na uwadze, że \(x_2\) musi stać się \(x_0\).

  • Zadanie: znajdź równanie ogólne (\(Ax + By + C = 0\)) prostej, która przechodzi prez punkty \((1, 2)\), \((3, 4)\).

Odpowiedź

Należy podstawić dwa punkty do równania prostej przecinającej dwa punkty, albo do współczynników A, B, C. Mamy wtedy

A = 4 - 2 = +2
B = 1 - 3 = -2
C = 1(2-4) -2(1-3) = -2 -2*(-2) = -2 + 4 = +2 

Równanie prostej Ax + By + C = 0:
   2x - 2y + 2 = 0
  • Zadanie: napisz program, który pokoloruje półpłaszczyznę \(1x + 2y - 1200 < 0\) na zielono (2d_halfplane.py)

Odpowiedź
A, B, C = 1, 2, -1200

for x in range(S_WIDTH):
    for y in range(S_HEIGHT):
        r, g, b = 0, 0, 0
        ksi, eta = x, y
        if(x*A + y*B + C < 0):
            pixels[x, y] = (0, 255, 0)

Jak powinniśmy przyjąć warunki, aby opisać punkty, które są w trójkącie?

Spróbujmy rozwiązać to zadanie graficznie. Skorzystamy z tego, że mamy trzy proste oraz trzy kolory podstawowe. Pokolorujemy płaszczyznę:

  • Na czerwono wszystkie punkty gdzie \(k_0 < 0\),

  • Na zielono wszystkie punkty gdzie \(k_1 < 0\),

  • Na niebiesko wszystkie punkty gdzie \(k_2 < 0\),

Oczywiście podpiksele są niezależne, więc punkty gdzie \({k_0 < 0 \wedge k_1 < 0}\) będą zółte (R+G), \({k_0 < 0 \wedge k_2 < 0}\) różowe (R+B) itp.

Wynik działania programu 2d_triangle.py. Kursor myszy wskazuje punkt \((\xi, \eta) = (0,0)\).

Kod programu wygląda następująco. Wprowadziliśmy tutaj układ współrzędnych \((\xi, \eta)\), którego początek jest w środku okienka. Lista coefficients zawiera współczynniki \(A, B, C\) trzech prostych zgodnie z tym co policzyliśmy wcześniej.

x = [0, 40, 110]
y = [0, 80, 30]


coefficients = [
    [y[0] - y[1], x[1] - x[0], -y[0]*(x[1] - x[0]) + x[0]*(y[1] - y[0])],
    [y[1] - y[2], x[2] - x[1], -y[1]*(x[2] - x[1]) + x[1]*(y[2] - y[1])],
    [y[2] - y[0], x[0] - x[2], -y[2]*(x[0] - x[2]) + x[2]*(y[0] - y[2])],
]

print(coefficients)

for x in range(S_WIDTH):
    for y in range(S_HEIGHT):
        r, g, b = 0, 0, 0
        ksi, eta = x-S_WIDTH/2, y-S_HEIGHT/2
        if(ksi*coefficients[0][0] + eta*coefficients[0][1] + coefficients[0][2] < 0):
            r = 255
        if(ksi*coefficients[1][0] + eta*coefficients[1][1] + coefficients[1][2] < 0):
            g = 255
        if(ksi*coefficients[2][0] + eta*coefficients[2][1] + coefficients[2][2] < 0):
            b = 255
        pixels[x, y] = (r, g, b)

pixels[S_WIDTH//2, S_HEIGHT//2] = (127, 127, 127)

Dodatkowo punkt \((\xi, \eta) = (0, 0)\) oznaczamy na szary kolor.

Z tego ekranu możemy odczytać, że trójkątna część płaszczyzny to ta gdzie

  • \(k_0 < 0\),

  • \(k_1 < 0\),

  • \(k_2 < 0\).

Mając to na uwadze, możemy zmienić warunki w progamie, tak aby zakolorował tylko te punkty. Najprościej jest to zrobić przez odwrócenie warunków i pominięcie kolorowania piksela, jeśli któryś z nich jest prawdziwy.

for x in range(S_WIDTH):
    for y in range(S_HEIGHT):
        r, g, b = 0, 0, 0
        ksi, eta = x-S_WIDTH/2, y-S_HEIGHT/2
        if(ksi*coefficients[0][0] + eta*coefficients[0][1] + coefficients[0][2] > 0):
            continue
        if(ksi*coefficients[1][0] + eta*coefficients[1][1] + coefficients[1][2] > 0):
            continue
        if(ksi*coefficients[2][0] + eta*coefficients[2][1] + coefficients[2][2] > 0):
            continue
        pixels[x, y] = (0, 255, 0)

pixels[S_WIDTH//2, S_HEIGHT//2] = (127, 127, 127)

I otrzymamy nasz trójkąt (program 2d_triangle.py):

Rysowanie kształtów, obiektowo

Nasze dotychczasowe programy były dość proste. Wykonywaliśmy pewną ilośc obliczeń, zapełnialiśmy macierz pikseli i wyświetlaliśmy ją.

Pisanie programów w taki sposób ma pewne wady. Zastanówmy się, co by się stało, gdybyśmy chcieli narysować dwa niezależne trójkąty na ekranie? Albo trójkąt i kwadrat? Trójkąt i okrąg?

Jednym z rozwiązań tego problemu jest podejście obiektowe - napiszmy program, w którym zdefiniujemy ogólny sposób rysowania dowolnego trójkąta. A najlepiej, dowolnego kształtu, z których każdy przypadek sobie zaimplementujemy.

Aby to zrobić musimy dokonać pewnych zmian w kodzie 2d_triangle.py:

  • Zdefiniujmy obiekt klasy Shape, który ma metodę draw() (która nic nie robi),

  • Zdefiniujmy obiekt klasy Triangle, który dziedziczy po Shape i przekrywa metodę draw() taką, która coś robi,

  • Wyjmijmy kod rysujący trójkąt do funkcji, która przyjmuje trzy wierzchołki i macierz pikseli do której ma rysować - to będzie metoda draw() w klasie Triangle.

Przy okazji możemy zauważyć, że mamy dużo do czynienia z parami liczb, które reprezentują punkty na płaszczyżnie. Możemy zrobic sobie „pudełko” na dwie licznby, które nazwiemy Vec2 (każdy punkt można traktowac jako wierzchołek wektora zaczynającego się w \((0,0)\)). Zróbmy równiez sobie pudełko na trzy liczby o nazwie Vec3. Na razie będziemy go używać aby opisywac kolory (jako trójki RGB). (kolor też można traktowac jako wektor - przypomnij sobie kostkę RGB). Nazwy Vec2 i Vec3 są zwyczajowe.

Oto nasze dwa pudełka. W Vec3 dopisaliśmy metodę asTuple(), która zwraca nam trójkę w formie takiej jaką oczekuje pygame przy ustawianiu koloru piksela.:

class Vec2:
    def __init__(self, x, y):
        self.x = x
        self.y = y

class Vec3:
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z

    def asTuple(self):
        return (self.x, self.y, self.z)

A tak wygląda klasa Shape oraz jej klasa potomna Triangle:

class Shape:
    verts = None
    def draw(self, pixels):
        print("Nie wiem jak narysować nieokreślony kształt!")

class Triangle(Shape):
    def __init__(self, verts, color):
        self.color = color
        if len(verts) != 3:
            raise ValueError("Potrzebne są dokładnie 3 wierzchołki!")
        self.verts = verts
        
    def draw(self, pixels):
        verts = self.verts
        coefficients = [
            [
                verts[0].y - verts[1].y,
                verts[1].x - verts[0].x,
                -verts[0].y*(verts[1].x - verts[0].x) + verts[0].x*(verts[1].y - verts[0].y)
             ],
            [
                verts[1].y - verts[2].y,
                verts[2].x - verts[1].x,
                -verts[1].y*(verts[2].x - verts[1].x) + verts[1].x*(verts[2].y - verts[1].y)
             ],
            [
                verts[2].y - verts[0].y,
                verts[0].x - verts[2].x,
                -verts[2].y*(verts[0].x - verts[2].x) + verts[2].x*(verts[0].y - verts[2].y)
			 ],
        ]

        print(coefficients)

        WIDTH, HEIGHT = pixels.shape
        for x in range(WIDTH):
            for y in range(HEIGHT):
                ksi, eta = x ,y
                if(ksi*coefficients[0][0] + eta*coefficients[0][1] + coefficients[0][2] > 0):
                    continue
                if(ksi*coefficients[1][0] + eta*coefficients[1][1] + coefficients[1][2] > 0):
                    continue
                if(ksi*coefficients[2][0] + eta*coefficients[2][1] + coefficients[2][2] > 0):
                    continue
                pixels[x, y] = self.color.asTuple()
        

Zdefiniowane są w pliku shapes.py. Możemy użyć ich w następujący sposób:

#!/usr/bin/env python3
from shapes import *
import pygame as pg

S_WIDTH = 800
S_HEIGHT = 600

pg.init()
pg.display.init()

screen = pg.display.set_mode((S_WIDTH, S_HEIGHT), 0, vsync=True)
screen.fill([0, 0, 0])
pixels = pg.PixelArray(pg.display.get_surface())

# Żółty trójkąt
tri1 = Triangle([Vec2(0, 0), Vec2(0, 100), Vec2(100, 100)], Vec3(255, 255, 0))
tri1.draw(pixels)
# Czerwony trójkąt
tri2 = Triangle([Vec2(150, 100), Vec2(100, 200), Vec2(200, 200)], Vec3(255, 0, 0))
tri2.draw(pixels)

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]:
            running = False    
pg.quit()

Zwróć uwagę na to jak wygląda teraz nasz kod. W głównym pliku definiujemy dwa trójkąty, a procedura która zajmuje się ich rysowaniem jest wydzielona do klasy Triangle. Dzięki temu podzieliliśmy program na dwa poziomy skomplikowania:

  • w głównym pliku znajduje się wyższy poziom („Narysuj dwa trójkąty, żółty i czerwony”),

  • implementacjach klas znajduje się niższy poziom („w taki sposób rysuje się trójkąt”).

Wynik działania programu oo_triangles.py

  • Zadanie: Korzystając z nowych obiektów, narysuj więcej trójkątów na ekranie.

  • Zadanie: Dopisz klasę Rectangle która dziedziczy po Shape i reprezentuje prostokąt. Wybierz jak przekazać informację o położeniu i rozmiarze prostokąta oraz jego kolor. Sprawdź swoje rozwiązanie rysując kilka prostokątów.

Animowanie

Czas wprowadzić dynamikę do naszego programu. W ogólnej formie animowanie polega na obliczaniu parametrów obiektów w zależności od czasu.

Zmienimy szkielet naszego programu, przenosząc część odpowiedzialną za tworzenie obiektów do pętli gry. Aby otrzymac aktualną wartość czasu użyjemy funkcji pygame.time.get_ticks() która zwraca ilośc milisekund od rozpoczęcia programu.

Napiszemy animację która rusza czerwonym trójkątem po ścieżce w kształcie okręgu (czyli trójkąt będzie robić kółka na ekranie).

Punkty na okręgu możemy opisać korzystając z funkcji trygonometrycznych \(\sin\) oraz \(\cos\). Jeśli podamy jako ich parametr czas \(t\) (przeskalowany jakąś liczbą \(k\)) to otrzymamy serie punktów tworzących okrąg:

\[\begin{split}\begin{aligned} x &= \sin(kt) \\ y &= \cos(kt) \end{aligned}\end{split}\]

Wartości \(x\) oraz \(y\) zawierają się w przedziale \(\langle-1, 1\rangle\). W naszej grze jednostka jest wielkości jednego piksela, więc żeby zobaczyc ten ruch, musimy pomnożyć te wartości przez jakąś większą liczbę.

Położenie trójkąta możemy zmieniać przeliczając położenia jego wierzchołków, ale wygodniej będzie napisać funkcje która przesuwa wszystkie wierzchołki kształtu o dany wektor:

# Funkcja pomocnicza do przesuwania kształtu
def translate(shape: Shape, vec: Vec2):
    for k in shape.verts:
        k.x += vec.x
        k.y += vec.y

Złóżmy to wszystko w cały program (oo_anim.py):

#!/usr/bin/env python3
from shapes import *        
import pygame as pg
import math

S_WIDTH = 800
S_HEIGHT = 600

pg.init()
pg.display.init()

screen = pg.display.set_mode((S_WIDTH, S_HEIGHT), 0, vsync=True)
screen.fill([0, 0, 0])
pixels = pg.PixelArray(pg.display.get_surface())

# Funkcja pomocnicza do przesuwania kształtu
def translate(shape: Shape, vec: Vec2):
    for k in shape.verts:
        k.x += vec.x
        k.y += vec.y


pg.display.flip()

running = True
while running:
    t = pg.time.get_ticks()
    x = math.sin(t/5000) * 150
    y = math.cos(t/5000) * 150

    screen.fill([0, 0, 0])

    tri = Triangle([Vec2(350, 220), Vec2(300, 300), Vec2(400, 300)], Vec3(255, 0, 0))
    translate(tri, Vec2(x, y))
    tri.draw(pixels)
    pg.display.flip()

    events = pg.event.get()
    for event in events:
        if event.type in [pg.QUIT]:
            running = False    
pg.quit()

Dwie klatki programu ruszającego czerwonym trójkątem (oo_anim.py)

  • Zadanie: Ten program działa strasznie wolno! Czy możemy coś z tym zrobić? (Przypomnij sobie funkcje rysująca trójkąty)