Pietrzak Roman
Kemu Studio - yosh.ke.mu
Pierwsza wersja: 2004
Ostatnie zmiany: 2014.10.04
Wszelkie prawa zastrzeżone
Copyrights reserved


Programming of color gradients

Programowanie gradientów (płynnych przejść kolorów)

O czym to jest

Artykuł jest przeznaczony dla początkujących programistów.
Jest to krótkie wprowadzenie do tematu programowania gradientów, czyli płynnych przejść kolorów.

Co się z tego dowiesz

  • Najpierw opisujemy samo zagadnienie. Krótkie przypomnienie teorii koloru i kilka słów o interpolacji - w jak najbardziej strawnej postaci.
  • Potem piszemy troszkę prostego kodu. Chodzi o podanie konkretnych rozwiązań i przykładów - skupiamy się tu nad prostymi gradientami.
    Raz będzie to pseudokod (język domyślny), a czasami piszemy przykłady w PHP. Wybrałem PHP, bo wydaje mi się dość przejrzysty, kod jest minimalny, a przeniesienie tego na dowolny inny język nie jest problemem.
    Zwróćcie uwagę, że kod podawany tutaj, nie jest optymalny, ani napisany dobrym stylem - jego celem jest zrozumienie tematu.
  • Następnie spróbujemy rozważyć troszkę bardziej złożone gradienty i kończymy.

GRADIENT - teoria

KOLOR

Niezależnie od modelu software'owej reprezentacji koloru, przed wyświetleniem każdego pixela na monitorze, jego reprezentaca zamieniana jest na model RGB. Z tego powodu, w tym artykule mówimy tylko o RGB. Inne reprezentacje, a także teoria koloru (wbrew pozorom RGB nie oddaje pełnej palety widzianych przez oko barw) nie są tematem tego artykułu.

Model RGB pozwala na dowolne manipulowanie 3-ma składowymi koloru. R - czerwoną, G - zieloną, B - niebieską. Manipulujemy tutaj luminancją (świeceniem) tych składowych - odwrotnie, niż robią to malarze czy drukarze, którzy operują na przyciemnianiu składowych - dla nich, bardziej naturalny jest CMYK.

W momencie prezentacji koloru jako RGB, przyjmujemy zwykle, że są to 3 wartości z tego samego zakresu (np od 0 do 100), chociaż oko ludzkie ma inną czułość na każdą ze składowych.

RGB

Z założenia, w komputerkach wszystko kręci się wokół reprezentacji dwójkowej. Dlatego najbardziej naturalną będzie reprezentacja składowych RGB w zakresach od 0 do 255.

Z tego powodu, wygodnym jest zapis wartości RGB koloru w postaci szesnastkowej, jako 3 pary szesnastkowych cyfr. Wg szablonu:

Czyli FFFFFF daje nam piękną czystą biel (wszystkie trzy kolory na maxa). Natomiast 000000 - czerń (trzy kolory na zero).

Słówko o typowych zapisach hexa :
- w C, C++ i Javie zapisujemy z prefixem 0x, czyli np 0x000000, albo 0xFFFFFF,
- w HTMLu, CSSie itp. (zagadnienia związane z WWW), wymagają dodania znaku #. Odpowiednio mamy #000000, lub #FFFFFF,

GRADIENT - pierwsze podejście

Gradient powstaje przez "płynne" przejście wartości RGB pomiędzy jednym kolorem, a drugim. Ważne: każdą składową musimy rozpatrywać osobno.

Najbanalniejsze gradienty widać na rysunku powyżej. Mamy tu liniowe przejścia pojedyńczej wartości z zera do FF (czerwony: #000000 do #FF0000, zielony: #000000 do #00FF00, niebieski: #000000 do #0000FF).
Takie przejścia są banalne i łatwe do opisania prostym wzorem:
value = ( x / max ) * 255
max - to długość gradientu (np długość linii na której rysujemy gradient, czy szerokość gradientowanego prostokąta)
x - to położenie w gradiencie
value - uzyskana wartość składowej

Pseudokod który stworzy poziomą linię 100 pixelową, gradientowaną z czerni do niebieskiego wyglądałby więc np tak:
for (x = 0; x < 100; x++)
{
    PutPixel(x, 0, (x*255)/100);
}
Uzyskujemy tu liniowy rozkład liczb od 0..255. Czyli gradient od #000000 do #0000FF.

Jak uzyskać pozostałe dwie składowe ? Najprościej - przesuwając bitowo (o 8 bitow dla zieleni lub 16 bitow dla czerwieni), albo mnożąc arytmetycznie (razy 256 dla zieleni lub 65536 dla czerwieni):
czerwony = R << 16 = R * (256 * 256) = R * 65536
zielony = G << 8 = G * 256
niebieski = B

W związku z tym, powstaje nam prosty wzór, który jest dla nas PODSTAWĄ do pracy z kolorem:
value = ( R << 16 ) | ( G << 8 ) | B;
ten sam wzór - wersja z operatorami arytmetycznymi:
value = ( R * 65536 ) | ( G * 256 ) | B;
R, G, B - poszczególne składowe (w zakresie 0 - 255)
value - pełny kolor
<< - to w C, Javie i PHP operator przesunięcia bitowego w lewo. Pascalowy odpowiednik to shl
| - to w C, Javie i PHP operator sumy logicznej (OR). Pascalowy odpowiednik to or

Przykład:
W celu otrzymania gradientu czarny-żółty, mixujemy wzrost liniowy R z takim samym wzrostem G:
for (x = 0; x <= 100; x++)
{
    value = (x*255)/100;
    PutPixel(x, 0, (value << 16) | (value << 8));
}

GRADIENT - drugie podejście

Równie prosty efekt, polega na odwróceniu przyrostu składowych gradientu względem siebie. Np czerwony rosnie, podczas gdy niebieski maleje. Efekt uzyskamy przez proste odejmowanie od maxa:
for (x = 0; x <= 100; x++)
{
    value = (x*255)/100;
    PutPixel(x, 0, (value << 16) | ((255-value) << 8));
}

Podsumowanie teorii

Właściwie, koder z fantazją, mógłby w tym miejscu odpuścić sobie pozostałą część artykułu. W dalszej części, przejdziemy tylko w trochę bardziej zaawansowane matematycznie efekty i ich modele...

GRADIENT - konkrety

W powyższych przypadkach rozważamy tylko bardzo proste gradienty. A co jeśli chcielibyśmy zrobić płynne przejście z koloru A do koloru B ? Co jeśli kolory A i B są dla nas nieznane na etapie tworzenia kodu (nie możemy wówczas zdefiniować stałych wartości we wzorze) ?

W tym momencie z pomocą przychodzi interpolacja. Bez strachu. Pojęcie interpolacji śni się studentom matematyki i informatyki po nocach. Ale w tym zastosowaniu jest potrzebna tylko w bardzo prościutkiej postaci...

W 90% wystarczą nam gradienty liniowe. Tzn liniowe przejścia z koloru A do koloru B. Dlatego:

Interpolacja liniowa - z punktu widzenia użycia w tworzeniu gradientu

Mamy liczbę A i liczbę B oraz liczbę kroków max, która oznacza ilość żądanych kroków pomiędzy liczbą A i liczbą B. Nasze zadanie polega na tym, żeby wyliczyć wartość value, która odda liniowo (proporcjonalnie) wartość spomiędzy A do B w punkcie x.
Przykład
A = 100, B = 200, max = 5.
Czyli mamy 6 kroków od A do B - bo zerowy krok też liczymy (więc max + 1).
W tym:
krok(0) = A = 100
krok(5) = B = 200
Należy więc rozłożyć pozostałe 4 kroki równo (liniowo) na zakresie <100 ; 200>.

I tu się przyda mały wzorek, który jest rdzeniem interpolacji liniowej w gradiencie:
value = (B - A)*pos/max + A;
A - wartość z której zaczynamy
B - wartość na której kończymy
max - ilość kroków (np pixeli w linii albo klatek w filmie)
pos - aktualny krok, czyli pozycja, z przedziału <0 ; max>

3 kanały w interpolacji

Podczas interpolowania należy oczywiście każdy kanał liczyć oddzielnie. Typowo tworzymy funkcję interpolującą (która jako argumenty przyjmuje wartości A, B, pos, max, a zwraca nam zinterpolowany kolor) w takim schemacie:
1. Separujemy składowe RGB z wejść A i B - np do tablicy.
2. Na każdej parze składowych pobranych z kolorów A i B wykonujemy interpolację i wynik umieszczamy w tablicy C.
3. Łączymy składowe z tablicy C do koloru i zwracamy wynik.

Rozwiązanie ideowe napisane w PHP:
function Interpolate2Colors($A, $B, $pos, $max_pos)
{
    // Separujemy kanały
    $A_R = $A >> 16;
    $A_G = ($A >> 8) & 0xff;
    $A_B = $A & 0xff;

    $B_R = $B >> 16;
    $B_G = ($B >> 8) & 0xff;
    $B_B = $B & 0xff;

    //Interpolujemy wartości na kanałach
    $C_R = (($B_R - $A_R)*$pos)/$max_pos + $A_R;
    $C_G = (($B_G - $A_G)*$pos)/$max_pos + $A_G;
    $C_B = (($B_B - $A_B)*$pos)/$max_pos + $A_B;

    //Scalamy kanały wyniku
    $C = ($C_R << 16) + ($C_G << 8) + $C_B;

    return $C;
}

Skrócony pseudokod :
function Interpolate2Colors(A, B, pos, max)
{
    C = (((B & 0xff) - (A & 0xff))*pos)/max + (A & 0xff); // liczymy B
    C = C + ((( ((B >> 8) & 0xff) - ((A >> 8) & 0xff) )*pos)/max + ((A >> 8) & 0xff)) << 8; // dodajemy G
    C = C + ((( ((B >> 16) & 0xff) - ((A >> 16) & 0xff) )*pos)/max + ((A >> 16) & 0xff)) << 16; // dodajemy R
    return C;
}
(nawiasy są pokolorowane tylko po to żeby było łatwiej się połapać...)

I kod który wykorzysta tą funkcję, do wygenerowania poziomej linii (od zółtego - 0xFFFF00, do różowego - 0xFF00FF):
for (x = 0; x <= 100; x++)
{
    PutPixel(x, 0, Interpolate2Colors(0xFFFF00, 0xFF00FF, x, 100) );
}

Optymalizuj i ograniczaj

Kolejny raz zaznaczam, że powyższe przykładowe źródełka, są ideowe. W przypadku, gdy gradientujemy pixel po pixelu, to powyższe kody będą za wolne. Aż proszą się o optymalizacje. Podobnie, w zależności od sposobu użycia, należy zastosować jakieś sprawdzanie zakresów (np. aby zapobiec wynikom ujemnym, lub co gorsza podawać wartości wyższe niż 0xFF) - oczywiście tylko, gdy to jest konieczne.

Kilka uwag z doświadczenia:
  • ścisłe typy i co za tym idzie ograniczenie wartości - należy zadbać, żeby wartości na wejściu nigdy nie były inne niż 3 razy kanał <0 ; 255>. Jakiekolwiek ujemne wyliczenia lub przekroczenie zakresu, spowoduje problemy.
  • w językach z typami, należy przemyśleć, gdzie w kodzie użyć signed int, a gdzie unsigned int. W pewnych okolicznościach można się zdecydować na float'y (choćby do tego, żeby tylko jednokrotnie liczyć współczynik (pos/max) ).
  • w C czy C++ warto zastosować unie - dobry kompilator sam zoptymalizuje rozdzielenia i scalenia kanałów.
  • warto użyć assemblera do optymalizacji operacji na kanałach RGB - np. dla CPU z rodziny i386 pomogą instrukcje MMX, dla core'ów SH4 mnożenia wektorowe/macierzowe,

GRADIENT - rozwinięcie

Multigradient - ciąg liniowych gradientów

Wygenerowanie liniowego gradientu z ciągu n kolorów, to tak naprawdę suma niezależnych n - 1 gradientów.

Np funkcja generująca gradient z 3 kolorów:
function Interpolate3Colors($c1, $c2, $c3, $value, $max_value)
{
    if ($value < $max_value/2)
        $color = Interpolate2Colors($c1, $c2, $value, $max_value/2);
    else
        $color = Interpolate2Colors($c2, $c3, $value - $max_value/2, $max_value/2);
    return $color;
}
W tej funkcji, na sztywno zdefiniowano, że w połowie długości <0; max_value>, ma być kolor c2.

Podobna funkcja została użyta do generowania dynamicznych progress-barów w jednym z projektów ke.mu:

Pełny rezultat można obejrzeć TUTAJ.

Dla pełnego wachlarza możliwości (dla gradientowania n kolorów), polecam użyć wektora, tablicy, czy listy. W każdym elemencie definiującym jeden kolor potrzebujemy wtedy:
- wartość RGB do interpolacji,
- położenie tej wartości w całej długości gradientu (np w skali <0 ; 1000> - tak by się łatwo/szybko przeliczało na dowolny gradient końcowy),

Wtedy do generacji wartości w danym punkcie, przeszukujemy wektor (tablicę/listę), w poszukiwaniu elementu zdefiniowanego przed i elementu za danym punktem.

Rozważmy przykład Summer Fields:
definicja gradientu:
{
    gradient(0) - kolor: 0x8080FF, pozycja: 0
    gradient(1) - kolor: 0xC0FFFF, pozycja: 420
    gradient(2) - kolor: 0xFFFFFF, pozycja: 499
    gradient(3) - kolor: 0x008000, pozycja: 500
    gradient(4) - kolor: 0xC0FFC0, pozycja: 570
    gradient(5) - kolor: 0x008000, pozycja: 1000
}
oczywiście, nie rozważamy sposobu w jaki powyższa definicja jest zakodowana - rodzaj użytej struktury danych zależy tu już od programisty.

Załóżmy, że szukamy wartości koloru w punkcie 530 gradientu:

Używamy interpolacji liniowej. Potrzebne są nam więc tylko wartości sąsiednie (najbliższe) 530.

Postępowanie:
1. Przeszukujemy naszą strukturę danych zawierającą definicję gradientu, w poszukiwaniu wartości najbliższych. Widać, że są to kroki numer 3 i 4 (bo gradient(3) jest w pkt 500, a gradient(4) w pkt 570).
2. Przeliczamy interpolację standardową funkcją (Interpolate2Colors) podając jej:
- pierwszy kolor: gradient(3) 0x008000
- drugi kolor: gradient(4) 0xC0FFC0
- pos: nasza poszukiwana pozycja minus pozycja gradient(3) 530 - 500 = 30
- max_pos: różnica między pozycjami gradient(3) i gradient(4) 570 - 500 = 70

Przykłady prostych gradientów uzyskanych tą metodą:


Iloczyn, czyli nakładanie

Praktycznie każdy gradient na linii (jednowymiarowy), można zrealizować za pomocą sumy przedstawionej wyżej. Natomiast w dwóch wymiarach (i więcej ?) przydaje się mnożenie gradientów.

W wielu przypadkach, wystarczy mnożyć kanały koloru przez liczbę (gradient monochromatyczny).

Np mnożenie przez gradientu kolorowego gradient monochromatyczny z zakresu <0 ; 1>, da efekt cieniowania. Tak jest zrealizowane cieniowanie w progress-barach przedstawionych wyżej.
Przykład:
Rysujemy gradientowany i cieniowany prostokąt 100x100.

Gradient podstawowy (od 0xFFFF00 do 0x00FFFF), rozkładamy w osi X. Mnożymy go przez gradient monochromatyczny rozłożony w osi Y.
for (y = 0; y <= 100; y++)
{
    alpha = 100 - y;
    for (x = 0; x <= 100; x++)
    {
        color = Interpolate2Colors( 0xFFFF00, 0x00FFFF, x, 100);
        color_cieniowany = Interpolate2Colors( 0x000000, color, alpha, 100);
        PutPixel(x, y, color_cieniowany);
    }
}
UWAGA: raz jeszcze przypominam, że kod tu przedstawiany jest NIEOPTYMALNY (a powyższy, wręcz woła o pomstę). NIE stosować bez przemyślenia i optymalizacji.

Powyższy kod wygeneruje:


Ciekawe efekty daje też przekraczanie zakresu <0 ; 1>, szczególnie w górę. Uzyskujemy wtedy efekt przesycenia. Przy dobranych parametrach, uzyskujemy łatwo wrażenie specular (odbijania światła), co daje efekt szklanej/metalicznej powierzchni...
W takim przypadku należy zwrócić uwagę, żeby ostateczne składowe przed scaleniem obcinać do <0 ; 255> (żeby kanały nie przebijały się pomiędzy sobą - chociaż i w takim przypadku da się uzyskać ciekawe efekty).

Kolejną wariacją, jest mnożenie przez siebie wielu gradientów kolorowych. Polecam experymentować z tym tematem...

Suma ważona

Ciekawe gradienty uzyskuje się przez zastosowanie sumy ważonej. Niezależnie od podejścia i rzeczywistej implementacji (można to zrobić na wiele sposobów), całość sprowadza się do tego, że definiuje się kilka punktów przestrzeni (płaszczyzny), każdemu nadając kolor i moc.
Następnie dla każdego pixela generowanego gradientu badamy odległość od wszystkich (optymalizacja!) zdefiniowanych punktów w opisie gradientu. Tą odległość pomnożoną przez moc dla każdego gradientu uznajemy za wagę (wpływ).
Następnie obliczamy sumę ważoną wszystkich (optymalizacja!) punktów koloru dla danego pixela.

Powyżej jest użyte określenie odległość. Także tu można ciekawie podefiniować znaczenie tego pojęcia (osobno dla każego zdefiniowanego punktu !). Można np brać pod uwagę tylko odległość (przesunięcie, różnicę) w jednym wymiarze, albo dysproporcjonować wymiary...

Opcji jest baaardzo wiele. W połączeniu z pozostałymi technikami (plus nieliniowości) dają niesamowite rezultaty.

W tym miejscu jedynie pokażę prosty przykład (typowa skala kolorów z programów graficznych):


Interpolacja nieliniowa

Bardzo ciekawe efekty daje zastosowanie interpolacji nieliniowej. W pomysłowo dobranych funkcjach, taki gradient może przestać przypominać gradient :)

Sądzę, że jeśli ktoś dobrnął do tego punktu, to już sam ma setki pomysłów, jakich funkcji użyć i jak je ze sobą łączyć. Np, żeby uzyskać taki efekt:


Koniec

Temat ten jest bardzo obszerny, a to jest tylko wprowadzenie - dziękuję za przeczytanie...

Mam nadzieję, że komuś się to przyda. Byłbym wdzięczny za wszelkie sugestie na adres yosh(at)ke(dot)mu

O kopiowaniu

Ten artykuł może być kopiowany w inne miejsca, po uprzednim uzyskaniu zgody autora.
Artykuł powinien być kopiowany W CAŁOŚCI (z rysunkami) oraz powinien zawierać link do yosh.ke.mu (z dopiskiem, że stąd pochodzi).

JavaScript failed !
So this is static version of this website.
This website works a lot better in JavaScript enabled browser.
Please enable JavaScript.