Pracując z elementem CANVAS spotykam się często z dwoma zagadnieniami: pobrania koloru piksela i kreślenia piksela. Jeśli chodzi stawianie punktu, to zazwyczaj spotykamy się z następującą metodą:
var ctx = document.querySelector('canvas').getContext('2d');
ctx.fillStyle = 'rgba(255, 0, 0, 1)';
ctx.fillRect(100, 100, 1, 1);
Z zmiennej ctx mamy obiekt RenderingContext pozwalający na dostęp przez dwie współrzędne (stąd parametr '2d'). Punkt stawiamy poprzez nakreślenie wypełnionego prostokąta o wymiarach 1x1 (parametr 3 i 4) na współrzędnych 100,100 (parametr 1 i 2). Wcześniej ustawiliśmy kolor czerwony dla wypełnienia. Domyślny kolor wypełnienia to czarny, a domyślny kolor tła CANVAS to biały. I ta metoda oczywiście się sprawdza, ale niekoniecznie przy dynamicznych efektach na CANVAS, kiedy potrzebujemy postawić tych punktów tysiące lub dziesiątki tysięcy.
Jak być szybszym?
Piksele w CANVAS są rezprezentowane przez obiekt ImageData, do którego mamy dostęp poprzez odczyt, ale również i zapis. Możemy go również stworzyć i przenieść do CANVAS.
Aby dostać się do obiektu ImageData, API udostępnia 3 następujące metody:
- createImageData - jak wspomniałem wyżej, dzięki tej metodzie można stworzyć obiekt,
- getImageData - w ten sposób pobieramy obiekt z CANVAS,
- putImageData - utworzony lub pobrany obiekt możemy dzięki tej metodzie ponownie umieścić w CANVAS.
Operując na obiekcie ImageData trzeba wziąć pod uwagę, że reprezentuje on piksele w postaci R, G, B, A. Oznacza to, że reprezentacja każdego piksela jest oddalona o 4 bajty od kolejnej.
Metoda getImageData przyjmuje 4 parametry: dwie współrzędne początka wycinka, który chcemy pobrać i dwie kolejne jako wymiar tego wycinka. Oznacza to, że nie musimy pobierać całej reprezentacji CANVAS.
W przypadku metody putImageData mamy trochę więcej możliwości. Funkcja przyjmuje 7 parametrów:
- obiekt ImageData,
- współrzędna X obszaru ImageData, z którego będziemy kopiować,
- współrzędna Y analogiczna do powyższej,
- opcjonalna współrzędna X obszaru docelowego,
- opcjonalna współrzędna Y, analogiczna do powyższej,
- opcjonalna szerokość obszaru, który chcemy skopiować,
- opcjonalna wysokość obszaru (jak wyżej).
Korzystając z powyższych dwóch wariantów sprawdźmy jak wygląda pokrycie obszaru 256x256 pikselami o losowych kolorach.
<canvas width="256" height="256"></canvas>
+
var canvas = document.querySelector('canvas');
var ctx = canvas.getContext('2d');
for (var y = 0; y < canvas.height; y++) {
for (var x = 0; x < canvas.width; x++) {
ctx.fillStyle = 'rgba(' + parseInt(Math.random() * 255) + ',' + parseInt(Math.random() * 255) + ',' + parseInt(Math.random() * 255) + ',1)';
ctx.fillRect(x, y, 1, 1);
}
}
Natomiast przy użyciu dostępu do ImageData:
var canvas = document.querySelector('canvas');
var ctx = canvas.getContext('2d');
var imageData = ctx.createImageData(canvas.width, canvas.height);
for (var y = 0; y < canvas.height; y++) {
for (var x = 0; x < canvas.width; x++) {
imageData.data[((x + y * imageData.width) << 2) + 0] = parseInt(Math.random() * 255);
imageData.data[((x + y * imageData.width) << 2) + 1] = parseInt(Math.random() * 255);
imageData.data[((x + y * imageData.width) << 2) + 2] = parseInt(Math.random() * 255);
imageData.data[((x + y * imageData.width) << 2) + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
Myślę, że obydwa kody są czytelne. Pochylmy się tylko nad tym co się dzieje wewnątrz pętli drugiej metody.
Utworzony wcześniej obiekt ImageData mamy pod zmienną imageData, a piksele "siedzą" w tablicy pod właściwością data tej zmiennej. Czterokrotnie wstawiamy do niej dane. Dlaczego? Jak wcześniej wspomniałem piksel jest rezprezentowany przez 4 bajty. Trzy z nich to składowe R, G, B. Ostatni to kanał alpha czyli przezroczystość danego piksela. Należy pamiętać, że przezroczystość podajemy jako liczbę z zakresu 0-255, a nie 0-1 jak w przypadku CSS. Tu tworzymy nieprzezroczysty piksel.
A czym jest wyrażenie wewnątrz kwadratowych nawiasów?
Jest to obliczenie "współrzędnej" danego piksela wewnątrz ImageData. Odciętą mamy pod x, kolejne rzędne (Y) otrzymujemy przesuwając się o szerokość obiektu (tu akurat szerokość ImageData jest równa szerokości CANVAS). Tak więc offset dla punktu 7,15 wynosi: 7+15*256=3847. A ten dziwny znaczek <<? Jest to przesunięcie logiczne w lewo o dwa bity, co odpowiada mnożeniu przez 4 (pisałem o tym we wpisie: Typowo męska logika, czyli fraktal Sierpińskiego.). Dla każdej składowej dodajemy odpowiedni 0, 1, 2, 3. W ten sposób celujemy dokładnie w reprezentacje każdej składowej koloru piksela.
Powyższy przykład możemy uprościć, ale przed tym jeszcze ciekawostka. Wpis dotyczy nie tylko samych metod dostępu do CANVAS, ale i szybkości tych metod. I jeśli na szybkości nam zależy, to warto używać drugiej metody.
100x100 | 256x256 | 512x512 | |
fillRect | 280 ms | 1470 ms | 3800 ms |
ImageData | 55 ms | 100 ms | 260 ms |
Wygląda na to, że czym większy obszar przetwarzamy, tym wynik jest dokładnieszy i wskazuje na 14-krotne przyspieszenie metody korzystającej z ImageData względem fillRect.
A teraz wspomniana optymalizacja wnętrza pętli drugiej metody:
var offset = (x + y * imageData.width) << 2;
imageData.data[offset++] = parseInt(Math.random() * 255);
imageData.data[offset++] = parseInt(Math.random() * 255);
imageData.data[offset++] = parseInt(Math.random() * 255);
imageData.data[offset] = 255;
Metoda ta została wykorzystana we wpisie Podróż do wnętrza kineskopu, czyli emulacja RGB, który przy okazji Ci polecam.
Operując tylko i wyłącznie na nieprzezroczystych pikselach, poza pętlą można ustawić kanał alpha na 255, a w pętli modyfikować tylko składowe R, G, B. A kiedy stawiamy piksele jeden za drugim, to zamiast obliczać offset, można po prostu zwiększać go z każdym krokiem pętli o 4 (czyli de facto inkrementować offset o 1 przy ustawianiu każdej z 4 składowych).
Czy można być jeszcze szybszym?
Nie da się ukryć, że 4 bajty przypadające na składowe i kanał alpha, to 32 bity. Czy można więc zapisywać zamiast 4 kolejnych bajtów jedno długie słowo (tzw. longword mający własnie 32 bity)? Oczywiście, że tak. W JavaScript dostępne są typowane tablice, na których możemy operować jakbyśmy to robili na surowych danych. Musimy dane wstawiać do tablicy Uint32Array, a potem przekształcić ją na Uint8ClampedArray. Tego drugiego typu jest właśnie data w ImageData.
var canvas = document.querySelector('canvas');
var ctx = canvas.getContext('2d');
var imageData = ctx.createImageData(canvas.width, canvas.height);
var offset = 0;
var i32 = new Uint32Array(canvas.height * canvas.width);
for (var y = 0; y < canvas.height; y++) {
for (var x = 0; x < canvas.width; x++) {
i32[offset++] = 0xFF000000 | (parseInt(Math.random() * 255) << 8 | parseInt(Math.random() * 255)) << 8 | parseInt(Math.random() * 255);
}
}
var i8 = new Uint8ClampedArray(i32.buffer);
imageData.data.set(i8);
ctx.putImageData(imageData, 0, 0);
W powyższym przykładzie tworzymy tablicę 32-bitowych integerów bez znaku. Do niej wstawiamy sklejone składowe + kanał alpha. Nie musimy się obawiać o inkrementację o 4 - inkrementując o jeden przesuwamy się o jeden 32-bitowy element tablicy. Po wypełnieniu tablicy tworzymy z niej tablicę bajtów bez znaku (czyli takiego typu jak data). Nie możemy jej bezpośrednio przypisać do zmiennej data, do tego służy metoda set.
To co wydaje się tu dziwne, to budowanie longword w odwrotnej kolejności, tj. ABGR. Tak się dzieje w przypadku kiedy procesor naszego komputera przechowuje dane binarne w systemie Little Endian. Najbardziej znaczący bajt słowa jest na końcu. Jego przeciwieństwem jest zasada Big Endian. Ciekawostką, wcale nie pocieszającą, jest fakt, że sami musimy dbać o poprawność układu - powyższy skrypt nie zadziała poprawnie na przeglądarce komputera z procesorem pracującym na Big Endian.
I na koniec warto pokazać różnice czasowe w stosunku do dwóch wcześniejszych metod:
100x100 | 256x256 | 512x512 | |
fillRect | 280 ms | 1470 ms | 3800 ms |
ImageData | 55 ms | 100 ms | 260 ms |
Uint32Array | 40 ms | 70 ms | 180 ms |
Jeśli chcesz być szybki i niewściekły, to wiesz z jakiej metody w przyszłości korzystać.
Przydatne linki:
Obiekt ImageData
Typowo męska logika, czyli fraktal Sierpińskiego.
Little Endian i Big Endian
Typ Uint32Array
Typ Uint8ClampedArray