Jak komputer rysuje okręgi?
W poprzednim artykule pokazałem, jaka algorytmika stoi za rysowaniem linii na ekranie. Zaczęliśmy od znanego wszystkim wzoru na funkcję liniową, aby przejść do optymalnego algorytmu, który na pierwszy rzut oka nie ma z nim nic wspólnego. Tym razem chciałbym kontynuować tematykę grafiki komputerowej i pokazać, jaka algorytmika stoi tym razem za rysowaniem okręgów.
Uwagi wstępne
Pierwsza uwaga jest taka, że w tym artykule nie robię wstępu teoretyczno-historycznego. Jeżeli nie znasz takich pojęć, jak piksel, bufor ramki czy za co odpowiada karta graficzna, zapraszam do poprzedniego artykułu: „Jak komputer rysuje linie?”
Druga uwaga jest taka, że podobnie jak ostatnio, dla prostego zobrazowania dalszych przykładów, będę pokazywać wszystkie operacje na tablicy symulującej bufor ramki. Oczywiście kod wszystkich zamieszczonych niżej prezentacji znajdziesz w repozytorium na moim GitHubie. Zostały one napisane w TypeScript z wykorzystaniem frameworka Svelte.
Zacznijmy od matematyki
Tradycyjnie dla naszych rozważań zacznijmy od wzoru, który możesz pamiętać z lekcji geometrii, czyli równania okręgu:
gdzie to punkt określający środek okręgu, a to jego promień.
Z lekcji matematyki możemy pamiętać też drugi wzór wykorzystujący funkcje trygonometryczne:
Z tego wzoru jednak nie będziemy korzystać w naszych algorytmach, ponieważ obliczanie funkcji trygonometrycznych jest bardzo kosztowne obliczeniowo. Mimo to wspominam o nim, gdyż przyda się nam na później.
Wróćmy do pierwszego ze wzorów. Często można się spotkać z jego uproszczoną wersją zakładającą, że punkt środkowy to :
Jednak z punktu widzenia matematyki napotykamy tutaj problem. Jeśli przekształcimy ten wzór do postaci, gdzie wyliczamy dla znanego (czyli tak, jak to robimy dla funkcji), otrzymamy:
Oznacza to, że dla każdego mamy dwie wartości . Problem polega na tym, że nie jest to już funkcja. Z definicji funkcja dla każdej wartości może przypisywać tylko jedną wartość .
Wykorzystanie symetryczności okręgu
Problem ten możemy jednak wykorzystać na naszą korzyść. Spójrzmy na to tak — robimy obliczenie raz, a dostajemy od razu dwa punkty, które są symetryczne względem osi X (zakładając, że środek to punkt ). Jednak to nie jedyna symetria, jaką znajdziemy w okręgu. Przede wszystkim mamy symetrię względem punktu środkowego, a do tego mamy też symetrię względem osi Y. Co więcej, możemy nawet wyznaczyć linie ze wzorów oraz , i również względem nich mamy symetrię.
Na rysunku zaznaczyłem punkt przecięcia okręgu z linią . Został on wyliczony ze wzoru na punkty okręgu, wykorzystujący funkcje trygonometryczne. Obliczenie to wygląda następująco:
Zastosowanie
Na potrzeby przykładu załóżmy, że nasz okrąg zawiera punkt . Z równania okręgu możemy wyliczyć, że: . Dzięki symetryczności jesteśmy w stanie od razu wyznaczyć 7 innych punktów:
- . Dowód:
- . Dowód:
- . Dowód:
- . Dowód:
- . Dowód:
- . Dowód:
- . Dowód:
Jeżeli punkt środkowy znajdowałby się w innym miejscu niż , to do współrzędnych wystarczy dodać położenie środka.
Przekładając to na język programowania, otrzymamy następujący kod:
function drawPoints(x, y, x0, y0) {
frameBuffer[x + x0][y + y0] = COLOR;
frameBuffer[y + x0][x + y0] = COLOR;
frameBuffer[y + x0][-x + y0] = COLOR;
frameBuffer[x + x0][-y + y0] = COLOR;
frameBuffer[-x + x0][-y + y0] = COLOR;
frameBuffer[-y + x0][-x + y0] = COLOR;
frameBuffer[-y + x0][x + y0] = COLOR;
frameBuffer[-x + x0][y + y0] = COLOR;
}
Oczywiście są przypadki, kiedy nie musimy wykonywać rysowania aż osiem razy. Gdy lub jedna ze współrzędnych jest równa , to wystarczy rysować tylko cztery razy. Jednak zanim usiądziemy do pisania optymalizacji przez dodanie warunku, należy sprawdzić, czy na pewno na tym zyskamy. Co prawda czas dostępu do pamięci jest dłuższy niż porównanie dwóch liczb, jednak te sytuacje mogą zajść raz bądź dwa razy w trakcie rysowania (na początku oraz na końcu). Stąd zanim byśmy mieli optymalizować, trzeba sprawdzić, czy koniec końców nie spowolnilibyśmy wykonania algorytmu. Pamiętajmy zarazem, że jest to optymalizacja bardzo niskopoziomowa, więc przy „zwyczajnej” implementacji nie odczujemy różnicy.
Wykorzystanie wzoru bezpośrednio
W takim razie przejdźmy już wprost do wykorzystania wzoru na równanie okręgu do narysowania go. Posiadając wzór na , najłatwiej będzie nam iterować po . Załóżmy, że obliczamy wartości dla części okręgu, którą na powyższym rysunku oznaczyłem cyfrą . Wtedy wyliczamy ze wzoru:
W kontekście programowania nie ma tutaj większej filozofii. Iterujemy po kolejnych wartościach . Teoretycznie moglibyśmy to robić tak długo, dopóki jest od niego większy, jednak dzięki rysowaniu ośmiu punktów na raz wystarczy, że będziemy iterować tak długo, jak . Kod prezentuje się następująco:
var x = 0;
var y = R;
while(x < R / Math.sqrt(2)) {
y = Math.round(Math.sqrt(R ** 2 - x ** 2));
drawPoints(x, y, x0, y0);
x++;
}
Algorytm możesz przetestować na poniższej prezentacji. Za pomocą narzędzi na dole wybierz, jaki promień powinien mieć rysowany okrąg, i włącz rysowanie. W trybie z włączoną animacją możesz zobaczyć, jak z każdym kolejnym krokiem iteracji przybywa nam osiem nowych punktów.
Algorytm punktu środkowego
Analogicznie jak przy rysowaniu linii tak i tutaj możemy wykorzystać algorytm punktu środkowego. Z góry zaznaczę, żeby nie mylić tego z punktem znajdującym się w środku okręgu, ponieważ są to dwie różne rzeczy. Podejście to również zostało opracowane przez J. E. Bresenhama, który opisał je w 1977 r., a następnie w patencie US4371933A z 1983 r. Patent wygasł w 2000 r., więc możemy spokojnie korzystać z jego osiągnięcia bez ponoszenia opłat.
Dla przypomnienia: algorytm punktu środkowego polega na tym, że nie obliczamy dokładnie ze wzoru piksela, na którym rysujemy. Zamiast tego sprawdzamy punkt znajdujący się pomiędzy dwoma pikselami (stąd nazwa). Z funkcji w postaci uwikłanej jesteśmy w stanie stwierdzić, czy sprawdzany punkt należy do okręgu, czy też znajduje się nad bądź pod nim. A skoro sprawdzamy punkt pomiędzy dwoma pikselami, to wiemy wówczas, czy zamalujemy górny piksel (tutaj zwany E), czy dolny (tutaj zwany SE).
Celem wykorzystania tego algorytmu jest zrezygnowanie z jakichkolwiek obliczeń zmiennoprzecinkowych tak, aby korzystać jedynie z operacji na liczbach całkowitych. Dokładniej mówiąc, rezygnujemy z zaokrągleń, pierwiastkowania i dzielenia, które używaliśmy w poprzednim podejściu. Jednak tutaj nie jest to na tyle „proste i oczywiste” jak w przypadku linii, dlatego będziemy krok po kroku przechodzić przez trzy wersje algorytmu. Najpierw wersja, w której wciąż mamy obliczenia zmiennoprzecinkowe, następnie przeniesienie jej na obliczenia całkowitoliczbowe, by skończyć na algorytmie, który maksymalnie upraszcza obliczenia, sprowadzając je do prostego dodawania.
Wersja 1: zmiennoprzecinkowa
Jak pamiętamy z rysowania linii, potrzebujemy równanie okręgu jako funkcję uwikłaną. Wówczas wiemy, że gdy funkcja jest równa 0, to punkt znajduje się na okręgu, gdy wynik jest dodatni, to na zewnątrz, a gdy ujemny, to wewnątrz. Postać uwikłana równania okręgu wygląda następująco:
Jak pamiętamy z rysowania odcinków, obliczaliśmy zmienne decyzyjne oraz , których wartość była wartością powyższej funkcji uwikłanej. Potem na ich podstawie obliczaliśmy przyrost oznaczany tutaj jako lub .
Najpierw sprawdzamy zmienną decyzyjną dla poprzedniego punktu:
Teraz mamy dwie możliwości, w jaki sposób obliczać następny punkt. Jeżeli , wybieramy górny piksel (). Wówczas:
Natomiast dla wybieramy dolny piksel (). Wtedy, jak możesz też zobaczyć na wcześniej pokazanym rysunku, musimy przenieść nasze obliczenia „w dół”, stąd musimy od dodatkowo odjąć 1.
W ten sposób wiemy, jak obliczać kolejne przyrosty w zależności od położenia poprzedniego punktu, zwanego punktem odniesienia. Jednak musimy od czegoś zacząć.
Pierwszy punkt, który rysujemy, to . Stąd możemy obliczyć położenie następnego punktu środkowego jako . W takim przypadku pierwsza wartość zmiennej decyzyjnej wynosi:
Tutaj właśnie otrzymujemy odpowiedź na to, dlaczego ta wersja jest zmiennoprzecinkowa. Niestety musimy użyć wartości . Jednak przenieśmy tę wersję na następujący kod:
var x = 0;
var y = radius;
var d = 5 / 4 - radius;
drawPoints(x, y, x0, y0);
while(y > x) {
if (d < 0) {
d += x * 2 + 3;
x++;
} else {
d += (x - y) * 2 + 5;
x++;
y--;
}
drawPoints(x, y, x0, y0);
}
A poniżej możesz zobaczyć prezentację działania. Zwróć uwagę na to, że okręgi nie zawsze są rysowane tak samo jak w poprzednim podejściu (np. dla promienia 11).
Wersja 2: całkowitoliczbowa
Teraz spróbujmy wyeliminować ułamek z początkowej wartości zmiennej decyzyjnej, ponieważ jest to jedyne miejsce, gdzie zachodzi jakakolwiek operacja zmiennoprzecinkowa. Dlatego że przyrost jest zawsze całkowitoliczbowy, możemy po prostu zastąpić jego zaokrągleniem, czyli .
Jednak żeby nie być gołosłownym, wyprowadźmy to matematycznie. Zacznijmy od zdefiniowania nowej zmiennej decyzyjnej, bazującej na tej, którą znamy z poprzedniej wersji:
Teraz, aby wszystko się zgadzało, to w warunku, zamiast robić porównanie , musimy wziąć pod uwagę przesunięcie, czyli porównamy . Zauważmy jednak jedną rzecz. przyjmuje na początku wartość całkowitą, a w wyniku dalszych zmian dalej mamy liczbę całkowitą. Oznacza to, że przyrównanie do ułamka nie ma najmniejszego sensu i możemy spokojnie je zastąpić porównaniem . Dzięki temu otrzymujemy następujący algorytm:
var x = 0;
var y = radius;
var d = 1 - radius;
drawPoints(x, y, x0, y0);
while(y > x) {
if (d < 0) {
d += x * 2 + 3;
x++;
} else {
d += (x - y) * 2 + 5;
x++;
y--;
}
drawPoints(x, y, x0, y0);
}
Możesz przetestować działanie poniżej. Zauważ, że mimo tego, że wartości są teraz inne niż w poprzedniej wersji, to jednak algorytm działa dokładnie tak samo.
Wersja 3: całkowitoliczbowa, przyrostowa
To, co do tej pory pokazałem, jest już bardzo mocno uproszczone i opiera się tylko na liczbach całkowitych. Jednak możemy dalej optymalizować te obliczenia, a więc skupmy się na tym, że do obliczenia zmiennej decyzyjnej cały czas wykorzystujemy wartości i . Jak pamiętasz, przy rysowaniu odcinka zmienna decyzyjna cały czas przyrastała o stałą wartość.
Obecnie zmienne decyzyjne obliczamy prostymi równaniami liniowymi, jednak każdy wielomian możemy obliczyć metodą przyrostową. W zasadzie już to robiliśmy, tylko z różnicami pierwszego rzędu. Teraz dołóżmy do tego różnice drugiego rzędu. Innymi słowy, obliczamy bezpośrednio funkcję w dwóch sąsiednich punktach, różnicę, a potem wykorzystujemy ją co iterację.
Zacznijmy od przypadku, kiedy w aktualnej iteracji wybierzemy piksel . Pamiętamy, że przesunięcie wówczas wynosiło 1 po . Jak pamiętamy, przyrost mogliśmy wtedy określić wzorem . Stąd przyrost dla kolejnego punktu, a potem różnicę drugiego rzędu, moglibyśmy określić następująco:
Analogicznie jest w tym przypadku dla :
Mamy jednak jeszcze drugi przypadek, czyli ten, gdy w aktualnej iteracji wybraliśmy piksel . Wtedy przesuwamy punkt zarówno po (zwiększamy o ), jak i po (zmniejszamy o ). W tym przypadku przyrosty wyglądają następująco:
Podsumowując, teraz w każdej iteracji do zmiennej decyzyjnej będziemy dodawać wartość przyrostu lub , zależnie od tego, który piksel właśnie wyrysowaliśmy. Oprócz tego wartości przyrostów będziemy zwiększać o podany wyżej zestaw wartości. Kod takiego algorytmu wygląda następująco:
var x = 0;
var y = radius;
var d = 1 - radius;
var deltaE = 3;
var deltaSE = 5 - radius * 2;
drawPoints(x, y, x0, y0);
while(y > x) {
if (d < 0) {
d += deltaE;
deltaE += 2;
deltaSE += 2;
x++;
} else {
d += deltaSE;
deltaE += 2;
deltaSE += 4;
x++;
y--;
}
drawPoints(x, y, x0, y0);
}
Natomiast w praktyce działa to następująco:
Podsumowanie
Powyżej przekonaliśmy się, że techniki, które wykorzystujemy w algorytmice w jednym celu, mogą się też po drobnych modyfikacjach sprawdzić w innych celach. Cały mechanizm matematyczny, który wykorzystaliśmy do tego, aby w optymalny sposób obliczać kolejne punkty znajdujące się na linii, tutaj wykorzystaliśmy do wyliczania punktów na okręgu. Tak więc po raz kolejny mogliśmy zobaczyć, że chcąc niechcąc matematyka w informatyce jest obecna, i stosując warsztat matematyczny, możemy udoskonalać pisane przez nas algorytmy.
Literatura
- Foley J. D. i inni, „Konwersja okręgów” w Wprowadzenie do grafiki komputerowej. Warszawa: Wydawnictwa Naukowo-Techniczne, 1995, s. 118-124