Poprawianie wydajności kanw HTML5

Wprowadzenie

Płótno HTML5, które powstało jako eksperyment firmy Apple, jest najczęściej używanym standardem grafiki w trybie natychmiastowym w internecie. Wielu deweloperów korzysta z tego formatu w różnych projektach multimedialnych, wizualizacjach i grach. Jednak w miarę zwiększania złożoności tworzonych przez nas aplikacji deweloperzy nieumyślnie wpadają w pułapkę wydajności. Istnieje wiele niespójności w informacjach dotyczących optymalizacji wydajności obrazu. Z tego artykułu deweloperzy mogą się dowiedzieć więcej o tych funkcjach w bardziej przystępnej formie. Z tego artykułu dowiesz się, jak optymalizować podstawowe elementy w ramach wszystkich środowisk graficznych, a także techniki specyficzne dla Canvas, które mogą się zmieniać wraz z ulepszaniem implementacji Canvas. W szczególności, gdy dostawcy przeglądarek wprowadzą przyspieszenie canvasa przez GPU, niektóre opisane techniki dotyczące wydajności prawdopodobnie będą miały mniejszy wpływ. W odpowiednich miejscach zostanie to zaznaczone. Pamiętaj, że ten artykuł nie dotyczy korzystania z tapety HTML5. W tym celu przeczytaj te artykuły na temat Canvas na stronie HTML5Rocks, rozdział na stronie Zagłębianie HTML5 lub samouczek Canvas w MDN.

Testy wydajności

Aby uwzględnić szybko zmieniający się świat tła HTML5, testy JSPerf (jsperf.com) sprawdzają, czy każda zaproponowana optymalizacja nadal działa. JSPerf to aplikacja internetowa, która umożliwia deweloperom pisanie testów wydajności JavaScript. Każdy test koncentruje się na wyniku, który chcesz uzyskać (np. wyczyszczeniu płótna), i zawiera kilka metod, które prowadzą do tego samego wyniku. JSPerf wykonuje każdą metodę tyle razy, ile to możliwe, w krótkim czasie i podaje statystycznie istotną liczbę iteracji na sekundę. Im wyższy wynik, tym lepiej. Odwiedzający stronę testu wydajności JSPerf mogą przeprowadzić test w swojej przeglądarce i zezwolić JSPerf na zapisanie znormalizowanych wyników w Browserscope (browserscope.org). Techniki optymalizacji opisane w tym artykule są poparte wynikami JSPerf, więc możesz do nich wrócić, aby uzyskać aktualne informacje o tym, czy dana technika jest nadal aktualna. Napisałem małą aplikację pomocniczą, która wyświetla te wyniki w postaci wykresów. Zostały one umieszczone w tym artykule.

Wszystkie wyniki dotyczące wydajności w tym artykule są powiązane z wersją przeglądarki. Okazuje się, że jest to ograniczenie, ponieważ nie wiemy, na jakim systemie operacyjnym działała przeglądarka ani, co ważniejsze, czy podczas testu wydajności canvas HTML5 był przyspieszany sprzętowo. Aby sprawdzić, czy kanwa HTML5 w Chrome jest przyspieszana sprzętowo, wpisz about:gpu w pasku adresu.

Wstępne renderowanie na płótnie poza ekranem

Jeśli na ekranie ponownie rysujesz podobne prymitywy w wielu klatkach, co często ma miejsce podczas pisania gry, możesz znacznie poprawić wydajność, wstępnie renderując duże części sceny. Prerenderowanie oznacza używanie osobnych off-screen canvas (lub off-screen canvasów), na których renderowane są tymczasowe obrazy, a następnie renderowanie off-screen canvasów z powrotem na widoczny ekran. Załóżmy na przykład, że rysujesz postać Mario biegnącą z prędkością 60 klatek na sekundę. Możesz narysować kapelusz, wąsy i literę „M” na każdym ujęciu lub wyrenderować Mario przed uruchomieniem animacji. bez prerenderowania:

// canvas, context are defined
function render() {
  drawMario(context);
  requestAnimationFrame(render);
}

renderowanie wstępne:

var m_canvas = document.createElement('canvas');
m_canvas.width = 64;
m_canvas.height = 64;
var m_context = m_canvas.getContext('2d');
drawMario(m_context);

function render() {
  context.drawImage(m_canvas, 0, 0);
  requestAnimationFrame(render);
}

Zwróć uwagę na requestAnimationFrame, które jest omawiane bardziej szczegółowo w następnej sekcji.

Ta technika jest szczególnie skuteczna, gdy operacja renderowania (drawMario w przypadku podanego powyżej przykładu) jest droga. Dobrym przykładem jest tutaj renderowanie tekstu, które jest bardzo kosztowną operacją.

Jednak słaba wydajność przypadku testowego „wstępnie wyrenderowanego luźno”. Podczas wstępnego renderowania ważne jest, aby tymczasowe płótno ściśle przylegało do obrazu, który rysujesz. W przeciwnym razie wzrost wydajności dzięki renderowaniu poza ekranem będzie równoważony spadkiem wydajności spowodowanym przez kopiowanie jednego dużego płótna na drugie (co zależy od rozmiaru docelowego źródła). W przypadku ciasnego obrazu w powyższym teście jest on po prostu mniejszy:

can2.width = 100;
can2.height = 40;

W porównaniu z luźną, która zapewnia gorszą wydajność:

can3.width = 300;
can3.height = 100;

Wykonywanie zbiorczych wywołań obszaru roboczego

Rysowanie jest kosztowną operacją, dlatego wydajniej jest wczytać maszynę stanów rysowania za pomocą długiego zestawu poleceń, a potem zrzucać je wszystkie do bufora wideo.

Na przykład podczas rysowania wielu linii lepiej jest utworzyć jedną ścieżkę z wszystkimi liniami i narysować ją za pomocą jednego wywołania funkcji draw(). Innymi słowy, zamiast rysować oddzielne linie:

for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.beginPath();
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
  context.stroke();
}

Lepszą skuteczność uzyskujemy dzięki rysowaniu pojedynczej ścieżki wielopunktowej:

context.beginPath();
for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
}
context.stroke();

Dotyczy to też świata HTML5 canvas. Podczas rysowania złożonej ścieżki lepiej jest umieścić wszystkie punkty na ścieżce, a nie renderować segmentów osobno (jsperf).

Pamiętaj jednak, że w przypadku Canvas istnieje ważne odstępstwo od tej reguły: jeśli prymitywy używane do rysowania żądanego obiektu mają małe ramki ograniczające (np. poziome i pionowe linie), renderowanie ich osobno może być wydajniejsze (jsperf).

Unikaj niepotrzebnych zmian stanu kanwy

Element HTML5 canvas jest implementowany na maszynie stanów, która śledzi takie elementy, jak style wypełnienia i obrysu, a także poprzednie punkty, które tworzą bieżącą ścieżkę. Podczas optymalizacji wydajności grafiki łatwo jest skupić się wyłącznie na jej renderowaniu. Manipulowanie maszyną stanów może jednak powodować wzrost obciążenia związanego z wydajnością. Jeśli do renderowania sceny używasz wielu kolorów wypełnienia, renderowanie według koloru jest tańsze niż według położenia na płótnie. Aby wyrenderować wzór pasków, możesz najpierw wyrenderować pasek, zmienić kolory i wyrenderować kolejny pasek itd.:

for (var i = 0; i < STRIPES; i++) {
  context.fillStyle = (i % 2 ? COLOR1 : COLOR2);
  context.fillRect(i * GAP, 0, GAP, 480);
}

Możesz też renderować najpierw wszystkie nieparzyste paski, a potem parzyste:

context.fillStyle = COLOR1;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2) * GAP, 0, GAP, 480);
}
context.fillStyle = COLOR2;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2+1) * GAP, 0, GAP, 480);
}

Zgodnie z oczekiwaniami podejście mieszane jest wolniejsze, ponieważ zmiana maszyny stanów jest kosztowna.

renderować tylko różnice na ekranie, a nie cały nowy stan;

Jak można się spodziewać, renderowanie mniejszej ilości elementów na ekranie jest tańsze niż renderowanie większej ilości. Jeśli różnice między poszczególnymi odświeżeniami są tylko przyrostowe, możesz znacznie zwiększyć wydajność, rysując tylko różnicę. Inaczej mówiąc, zamiast czyszczenia całego ekranu przed rysowaniem:

context.fillRect(0, 0, canvas.width, canvas.height);

Śledź narysowaną ramkę ograniczającą i usuń tylko ją.

context.fillRect(last.x, last.y, last.width, last.height);

Jeśli znasz się na grafice komputerowej, możesz też znać tę technikę jako „ponowną rysowanie regionów”, w której wcześniej wyrenderowany prostokąt ograniczający jest zapisywany, a następnie oczyszczany przy każdym renderowaniu. Ta technika dotyczy też kontekstów renderowania opartych na pikselach, co ilustruje ten JavaScriptowy emulator Nintendo.

Używanie wielu warstw obrazu w przypadku złożonych scen

Jak już wspomnieliśmy, renderowanie dużych obrazów jest kosztowne i należy go unikać, jeśli to możliwe. Oprócz renderowania ekranu wyłączonego za pomocą innego płótna, jak pokazano w sekcji dotyczącej wstępnego renderowania, możemy też używać obrazów nałożonych na siebie. Dzięki zastosowaniu przezroczystości w obszarze tła możemy polegać na GPU, który podczas renderowania będzie łączyć warstwy alfa. Możesz to skonfigurować w ten sposób, aby 2 płótna były umieszczone jedno na drugim.

<canvas id="bg" width="640" height="480" style="position: absolute; z-index: 0">
</canvas>
<canvas id="fg" width="640" height="480" style="position: absolute; z-index: 1">
</canvas>

Zaletą tego rozwiązania w porównaniu z jednym płótnem jest to, że gdy rysujemy lub czyścimy tło, nie modyfikujemy tła. Jeśli Twoja gra lub aplikacja multimedialna może być podzielona na pierwszy i drugi plan, rozważ renderowanie ich na osobnych obszarach roboczych, aby uzyskać znaczną poprawę wydajności.

Często możesz wykorzystać niedoskonałe ludzkie postrzeganie i renderować tło tylko raz lub z mniejszymi prędkościami niż w przypadku pierwszego planu (który przyciąga najwięcej uwagi użytkownika). Możesz na przykład renderować pierwszy plan za każdym razem, gdy renderujesz obraz, ale tło tylko co N klatek. Pamiętaj też, że to podejście sprawdza się w przypadku dowolnej liczby złożonych kanałów, jeśli Twoja aplikacja działa lepiej z takiego rodzaju strukturą.

Unikaj funkcji shadowBlur

Podobnie jak wiele innych środowisk graficznych, kanwa HTML5 umożliwia deweloperom rozmycie prymitywów, ale ta operacja może być bardzo kosztowna:

context.shadowOffsetX = 5;
context.shadowOffsetY = 5;
context.shadowBlur = 4;
context.shadowColor = 'rgba(255, 0, 0, 0.5)';
context.fillRect(20, 20, 150, 100);

Poznaj różne sposoby czyszczenia płótna

Ponieważ kanwa HTML5 jest paradygmatem rysowania w trybie natychmiastowym, scena musi być ponownie rysowana w każdej klatce. Dlatego czyszczenie kanwy jest bardzo ważną operacją w przypadku aplikacji i gier na kanwie HTML5. Jak wspomniano w sekcji Unikanie zmian stanu płótna, czyszczenie całego płótna jest często niepożądane, ale jeśli musisz to zrobić, masz 2 opcje: wywołanie funkcji context.clearRect(0, 0, width, height) lub użycie hacka dla danego typu płótna: canvas.width = canvas.width. W momencie pisania tego artykułu funkcja clearRect zazwyczaj działa lepiej niż wersja z resetem szerokości, ale w niektórych przypadkach użycie hacka canvas.width do resetowania jest znacznie szybsze w Chrome 14.

Stosuj tę wskazówkę z zastrzeżeniem, ponieważ zależy ona w dużej mierze od implementacji canvasa i może ulec zmianie. Więcej informacji znajdziesz w artykule Simona Sarrisa o czyszczaniu płótna.

Unikaj współrzędnych zmiennoprzecinkowych

Płótno HTML5 obsługuje renderowanie subpikselowe i nie można go wyłączyć. Jeśli rysujesz za pomocą współrzędnych, które nie są liczbami całkowitymi, automatycznie używa się antyaliasingu, aby wygładzić linie. Oto efekt wizualny pochodzący z tego artykułu Seb Lee-Delisle o wydajności canvasa w subpikselach:

Poniżej piksela

Jeśli nie potrzebujesz wygładzonego sprite’a, możesz znacznie szybciej przekształcić współrzędne na liczby całkowite za pomocą funkcji Math.floor lub Math.round (jsperf):

Aby przekształcić współrzędne zmiennoprzecinkowe na liczby całkowite, możesz użyć kilku sprytnych technik. Najskuteczniejsze z nich polegają na dodawaniu połowy do liczby docelowej, a następnie na wykonywaniu operacji bitowych na wyniku, aby wyeliminować część ułamkową.

// With a bitwise or.
rounded = (0.5 + somenum) | 0;
// A double bitwise not.
rounded = ~~ (0.5 + somenum);
// Finally, a left bitwise shift.
rounded = (0.5 + somenum) << 0;

Pełny opis wydajności znajdziesz tutaj (jsperf).

Pamiętaj, że tego typu optymalizacja nie będzie już potrzebna, gdy implementacje canvas będą przyspieszane przez GPU, co pozwoli na szybkie renderowanie współrzędnych niebędących liczbami całkowitymi.

Optymalizacja animacji za pomocą requestAnimationFrame

Interfejs API requestAnimationFrame to stosunkowo nowy, zalecany sposób implementowania aplikacji interaktywnych w przeglądarce. Zamiast wydawać przeglądarce polecenie renderowania z określonym stałym interwałem, możesz grzecznie poprosić ją o wywołanie procedury renderowania i o wywołanie, gdy będzie dostępna. Jeśli strona nie jest na pierwszym planie, przeglądarka jest na tyle inteligentna, że nie będzie jej renderować. W przypadku wywołania zwrotnego requestAnimationFrame docelowa liczba klatek na sekundę wynosi 60 FPS, ale nie jest to gwarantowane, dlatego musisz śledzić czas od ostatniego renderowania. Może to wyglądać mniej więcej tak:

var x = 100;
var y = 100;
var lastRender = Date.now();
function render() {
  var delta = Date.now() - lastRender;
  x += delta;
  y += delta;
  context.fillRect(x, y, W, H);
  requestAnimationFrame(render);
}
render();

Pamiętaj, że to użycie requestAnimationFrame dotyczy zarówno canvasa, jak i innych technologii renderowania, takich jak WebGL. W momencie pisania tego artykułu interfejs API jest dostępny tylko w Chrome, Safari i Firefox, więc należy użyć tego shimu.

Większość implementacji mobilnych canvas jest powolna

Porozmawiajmy o urządzeniach mobilnych. W momencie pisania tego artykułu tylko system iOS 5.0 w wersji beta z Safari 5.1 ma implementację mobilnego kanału wejściowego z przyspieszeniem sprzętowym. Bez akceleracji GPU procesory w przeglądarkach mobilnych zazwyczaj nie są wystarczająco wydajne do obsługi nowoczesnych aplikacji korzystających z tabeli. Niektóre z opisanych powyżej testów JSPerf działają na urządzeniach mobilnych o wiele gorzej niż na komputerach, co znacznie ogranicza rodzaje aplikacji wieloplatformowych, które można uruchomić.

Podsumowanie

Podsumowując, w tym artykule omówiliśmy kompleksowy zestaw przydatnych technik optymalizacji, które pomogą Ci tworzyć wydajne projekty na kanwie HTML5. Teraz, gdy już wiesz, jak to zrobić, możesz optymalizować swoje niesamowite kreacje. Jeśli nie masz obecnie gry ani aplikacji do optymalizacji, zaczerpnij inspiracji z eksperymentów w ChromeCreative JS.

Odniesienia