MerixGames

14 stycznia 2016

/ code & tools

Elementy HTML5 - przewodnik po canvas

Oskar Strasburger

Specyfikacja HTML5 zawiera wiele nowych elementów dla tego języka znaczników, jednym z nich jest canvas. Zanim przejdę do niego, wyjaśnię czym jest i jak z nim pracować, najpierw cofnijmy się trochę w czasie i poznajmy historię canvas.

Canvas – kilka słów wprowadzenia

Jest rok 2001, na świat przychodzi dziecko zwane SVG (Scalable Vector Graphics) - otwarty standard dla opisywania danych wektorowych w formacie XML. Jego deklaratywny format pozwala nam zbudować plik, w którym zawrzemy informację o tym co chcemy zobaczyć.

<svg height="100" width="100">
    <circle fill="black" stroke="red" stroke-width="3" cx="50" cy="50" r="40"  />
</svg> 

Można napisać plik SVG i umieścić w nim linie, krzywe, okręgi oraz wiele innych matematycznych elementów. Polega to na tym, że  nie określamy „narysuj tę linię, a następnie ten okrąg”, ale „Ta linia ma być tutaj, a ten okrąg tam”. A co jeśli chcemy utworzyć rozbudowaną scenę zawierającą setki, tysiące i więcej elementów? Opisywanie każdego elementu byłoby katorgą. Odpowiedź na to pytanie to canvas, który narodził się w 2008 roku.

Canvas czas start!

Oficjalnie jest to „element, który może być stosowany do wyświetlania bądź renderowania grafiki, grafiki gier lub innych obrazów graficznych w locie”. Przekładając to na język bardziej przyjazny, umożliwia on dynamiczne renderowanie linii, kształtów, tekstu, gradientów, obrazów bitmapowych oraz innych efektów przy użyciu języka skryptowego JavaScript w oparciu o canvas 2D API.

Ponadto canvas ma również możliwość, nie bezpośrednio, a z pomocą technologii WebGL, tworzenia grafiki trójwymiarowej w oparciu o akcelerację sprzętowej naszej karty graficznej.

Działanie canvas odbywa się oczywiście za pomocą JS, ale najpierw musimy zadeklarować w pliku HTML tag <canvas> nadając mu atrybut id by móc potem odwołać się do niego w naszym skrypcie.

<canvas id="nasz_canvas" width="400" height="200"></canvas>

W kodzie JS, po odwołaniu się do elementu, musimy również odwołać się do jego kontekstu (Drawing context). Na nim właśnie będziemy sporządzać nasze graficzne wizje. Każdy element canvas posiada własny kontekst, a więc mając ich większą ilość musimy odwołać się do każdego kontekstu indywidualnie.

<canvas id="nasz_canvas" width="400" height="200"></canvas>
<script type="text/javascript">
    var canvas = document.getElementById("nasz_canvas");
    var context = canvas.getContext("2d");
</script>

Jak to działa?

W canvasie bazujemy na układzie współrzędnych mający swój punkt początkowy (0,0) w lewym górnym rogu. Jako jednostkę przyjmujemy piksel. Aby zobrazować sposób przeliczania współrzędnych przygotowałem poniższą makietę z odwzorowaniem naszego układu współrzędnych (w tym przypadku fragment 20 x 10 pikseli).

See the Pen Base grid by Anita (@smotchi) on CodePen.

Stworzyłem przy tej makiecie funkcje (takie jak rysowanie kształtów czy dodawanie tekstu), aby ułatwić sobie pracę i przedstawić co się dzieje krok po kroku:

function addCircle(x, y, r, color, hideRadius) {
  x = x*step;
  y = y*step;
  r = r*step;
  context.beginPath();
  context.arc(x, y, r, 0, 2 * Math.PI, false);
  context.fillStyle = color || 'rgba(20,175,40,1)';
  context.fill();
  if (!hideRadius) {
    context.beginPath();
    context.moveTo(x, y-r);
    context.lineTo(x, y);
    context.lineWidth = 1;
    context.strokeStyle = '#ffffff';
    context.stroke();
  }
}
function addText(x, y, text, align) {
  context.beginPath();
  context.font = '9pt Calibri';
  context.fillStyle = 'black';
  context.textAlign = (typeof align == 'undefined')? 'left' : align;
  context.fillText(text, x*step, y*step);
});

2D API posiada funkcje tworzenia podstawowych kształtów, tzw. prymitywów. Kształty te są opisane na tej stronie, gdzie ponadto są podane i poparte przykładami wszystkie funkcje API (warto dodać do zakładek).

Moim zdaniem najtrudniejszą rzeczą do zrozumienia w canvasietransformacje, czyli przesunięcia, skalowanie, rotacja i inne. Głównie z tego powodu stworzyłem tę makietę. Transformacje nie działają bezpośrednio na dodany prymityw, a na cały element canvas, dlatego jeśli dodamy sobie dwa okręgi i będziemy chcieli jeden z nich obrócić, napiszemy coś takiego:

    drawGrid();
    addCircle(2, 2, 2, 'rgba(255,0,0,0.7)');
    rotate(Math.PI / 4) // rotacja przyjmuje wartości w radianach
    addCircle(2, 2, 2, 'rgba(0,255,0,0.7)');

a otrzymamy coś takiego:

See the Pen Example 1.1 by Burger (@Burger) on CodePen.

Co prawda widać, że nasze koło zostało obrócone, ale nie jest to oczekiwany przez nasz efekt. Proponuję wyrenderować jeszcze raz siatkę układu po transformacji i sprawdzić, co się stało.

    drawGrid();
    addCircle(2, 2, 2, 'rgba(255,0,0,0.7)');
    rotate(Math.PI / 4) // rotacja przyjmuje wartości w radianach
    drawGrid();
    addCircle(2, 2, 2, 'rgba(0,255,0,0.7)');

See the Pen Example 1.2 by Burger (@Burger) on CodePen.

Teraz wszystko jasne! Tak jak wspomniałem wcześniej, transformacja wykonała obrót na całym układzie, w punkcie (0, 0). Poprawmy zatem nasz kod i wyświetlmy oba okręgi nieco dalej od krawędzi, a rotacje wykonajmy względem środka drugiego okręgu. Aby to zrobić, musimy najpierw przesunąć układ współrzędnych do punktu środka pierwszego okręgu, wykonać rotację, a drugi okrąg umieścić w punkcie (0, 0), ponieważ ten punkt współrzędnych znajduje się teraz w punkcie środka pierwszego okręgu.

    drawGrid();
    addCircle(4, 4, 2, 'rgba(255,0,0,0.7)');
    translate(4, 4);
    rotate(Math.PI / 4) // rotacja przyjmuje wartości w radianach
    drawGrid();    
    addCircle(0, 0, 2, 'rgba(0,255,0,0.7)');

See the Pen Example 1.3 by Burger (@Burger) on CodePen.

Przy takim stanie rzeczy, każdy nowy element, który postanowimy dodać do sceny będzie dodany względem już obróconego układu, dlatego też wypadałoby ten układ „zresetować”.Możemy obrócić i przesunąć o minusowe wartości, ale przy większej ilości transformacji taki sposób nie będzie miał sensu i nie powinien mieć racji bytu. W takim przypadku należy użyć funkcji transformacji macierzowej.

    context.setTransform(1, 0, 0, 1, 0, 0);

Oprócz resetowania, mamy do dyspozycji zapisanie aktualnego stanu transformacji. Dzięki temu, wykonując kolejną transformację, możemy wrócić do poprzedniej. Funkcja save() zapisuje aktualny stan i odkłada na stos. Od tego momentu możemy wykonać kolejną transformację, pamiętając, że transformacja wykona się na podstawie stanu aktualnego - jeśli przesunęliśmy widok o 3 jednostki w prawo, to kolejna transformacja o 4 jednostki w prawo da nam przesunięcie dla elementów o 7 jednostek względem całego canvasa. Dla zobrazowania sytuacji:

See the Pen Example 1.4 by Burger (@Burger) on CodePen.

To jest oczywiste, ale po co właściwie nam te stany? Załóżmy, że rysujemy sobie 3 kwadraty, wszystkie chcemy obrócić o pewien kąt, natomiast drugiego z nich zamierzamy obrócić jeszcze bardziej względem tego samego punktu. Musimy więc pierwszy obrót zapisać na stos, dodać pierwszy kwadrat, wykonać drugi obrót, dodać drugi kwadrat, odczytać poprzedni stan i dodać trzeci kwadrat. Funkcja restore(); pobiera ze stosu ostatni zapisany stan, ustawia go i usuwa ze stosu. Po wykonaniu tych czynności canvas powinien wyglądać w ten sposób:

See the Pen Example 1.5 by Burger (@Burger) on CodePen.

Oto kroki, które musimy podjąć:

  • Przesuwamy kontekst o 4 jednostki w prawo
  • Obracamy kontekst o około 25 stopni
  • Zapisujemy stan na stos
  • Dodajemy pierwszy kwadrat w punkcie (1, 1)
  • Obracamy ponownie kontekst o 30 stopni
  • Dodajemy drugi kwadrat również w punkcie (1, 1)
  • Odczytujemy stan poprzedni
  • Dodajemy ostatni kwadrat w punkcie (1, 4), żeby nie przysłonił nam pierwszego.

Przełóżmy te punktu na nasz ulubiony język:

    translate(4,0);
    rotate(Math.PI / 7);
    context.save(); // zapisujemy stan na stos
    addRect(1,1,3,3,'red');
    rotate(Math.PI / 6);
    addRect(1,1,3,3,'blue');
    context.restore(); // odczytujemy ostatni zapisany stan, czyli przesunięcie (4,0) oraz rotacja (Math.PI/7) i usuwamy ze stosu
    addRect(1,4,3,3,'purple');

Dla łatwego zrozumienia powyższych komend, poczyniłem animację, ukazując krok po kroku co następuje:

See the Pen Example 1.6 by Burger (@Burger) on CodePen.

Wprawmy coś w ruch!

Animacja to nic innego jak następujące po sobie pewne funkcje rysujące elementy na canvasie. Musimy zatem wykorzystać jakąś funkcję, która pomoże nam wykonać rysowanie w odpowiednich odstępach czasowych. Jak na dzisiejsze standardy przystało, nasza animacja powinna wykonywać 60 klatek na sekundę. Idealnie sprawdzi się w tym przypadku funkcja setTimeout:

    function loop() {
        setTimeout(loop, 1000/60) // ~16.667ms
        render(); // funkcja renderująca pojedynczą klatkę
    }

No i pięknie! Teraz możemy zacząć animować! Ale czy na pewno? Ten przykład będzie działał, ale ma kilka minusów.

Pierwszy to fakt, że setTimeout nie bierze pod uwagę tego co się dzieje w przeglądarce. Strona może być otwarta w karcie, która nie jest aktualnie aktywna (widoczna) w przeglądarce, przez co będzie niepotrzebnie obciążać procesor.

Po drugie, setTimeout aktualizuje widok kiedy chce, a nie wtedy, gdy komputer jest w stanie to zrobić. Oznacza to, że nasza biedna przeglądarka musi pogodzić przerysowanie (przeliczenie) niezależnie od tego kiedy ekran się odświeża. Potrzeba na to więcej mocy obliczeniowej, a co za tym idzie znowu zużywamy więcej procesora i energii elektrycznej. A wynikiem tego wszystkiego będzie spadek FPSów. To oznacza, że nasza animacja będzie trwała dłużej, a więc przemieszczenie elementu w punktu A do punktu B będzie trwało dłużej niż założyliśmy.

Właściwe podejście do animacji

Co w takim razie, możemy zrobić, aby nie obciążać zbytnio komputera? Z odsieczą przybywa funkcja o nazwie requestAnimationFrame(). Jak sama nazwa wskazuje, funkcja ta informuje przeglądarkę, że zamierza wykonać animację i żąda od niej wywołania określonej funkcji w celu przerenderowania animacji przed następnym odświeżeniem ekranu. Nasza animacja osiągnie 60 FPS tylko wtedy, gdy nasz monitor będzie odświeżał się z częstotliwością 60 kHz (60 razy na sekundę). Zaktualizujmy nasz kod:

    function loop() {
        requestAnimationFrame(loop)
        render(); // funkcja renderująca pojedynczą klatkę
    }

Wygląda podobnie, ale czegoś brakuje, prawda? Tak, w tym przypadku nie określamy odstępu czasowego między klatkami, to przeglądarka decyduje kiedy to nastąpi i zazwyczaj jest to 60 FPSów. Jednym warunkiem jest to, czy nasz komputer będzie na tyle szybki, aby przeliczyć kod w odpowiednim czasie. 

Ale ale! Jeśli słabszy komputer pozwoli nam tylko na 30 FPSów, to nasz element nie przemieści się z punktu A do punktu B w tym samym czasie!

Dlatego też, podczas przeliczania pozycji naszego elementu, musimy wziąć pod uwagę różnicę czasową między klatkami (delta timing). Aby to zrobić, musimy zapisywać czas, w jakim wykonała się funkcja pojedynczej klatki i od niej odjąć wartość poprzedniej. Wówczas otrzymamy różnicę, którą musimy pomnożyć przez prędkość elementu, jeśli naszym celem jest pokonanie określonej drogi w określonym czasie.

    var lastFrame = Date.now();
    var speed = 10 / 3000 // droga przez czas w ms
    var x = 2; // aktualna pozycja elementu

    function render() {
        var now = Date.now();
        var deltaTime = (now – lastFrame);
            lastFrame = now;

        x += speed * deltaTime; // iterujemy pozycję

        clear(); // czyścimy widok
        addCircle(x, 5, 0.5); // rysujemy nasz element w danej pozycji

    }

    function loop() {
        requestAnimationFrame(loop)
        render(); // funkcja renderująca pojedynczą klatkę
    }

    loop();

Poniżej przykład animacji na podstawie mojej makiety, z podglądem pozycji i czasu, który upłynął.

See the Pen Example 1.7 by Burger (@Burger) on CodePen.

SVG czy Canvas?

SVG vs canvas

Wektor vs raster

SVG bazuje na grafice wektorowej, a canvas na pikselach, ale w pierwszym jak i w drugim przypadku można tworzyć linie na podstawie wzoru matematycznego.

Pliki vs skrypty

Obrazy SVG są zdefiniowane w XML, każdy element jest dołączany do DOM (Document Object Model), dzięki czemu może być modyfikowany za pomocą JS jak i CSS.

Zdarzenia

Do każdego elementu SVG możemy dołączyć obsługę zdarzenia lub zaktualizować jego właściwości za pomocą innego zdarzenia w dokumencie. Canvas rysuje piksele i na tym jego zadanie się kończy. Całą resztę musimy i tak zdefiniować w skrypcie.

Wsparcie przeglądarek

SVG posiada większe wsparcie przeglądarek. Ponadto wspiera tekst, więc jeśli przeglądarka nie wspiera SVG, wówczas wyświetli się użyty wewnątrz tekst. Canvas jest zależny od JavaScript, więc jeśli w przeglądarce będzie on wyłączony, wtedy zostanie nam tylko puste pole. Jest jednak na to sposób. Można użyć tagów <noscript> i wtedy przeglądarka wyświetli jego zawartość.

Wydajność

Przeglądarka przy większej liczbie elementów SVG może mieć problem z płynnym ich renderowaniem. W przypadku Canvas ilość elementów może być znacznie większa i przeglądarka lepiej sobie z tym poradzi. Minusem Canvasa w przypadku wydajności jest jego rozmiar - im większy tym potrzebne jest więcej mocy obliczeniowej.

Kiedy wybrać SVG a kiedy Canvas?

Wybór zależy oczywiście od projektu, dlatego też musimy przewidzieć jak bardzo będzie on rozbudowany, aby dopasować go wydajnościowo. Poniższy wykres przedstawia zależności wydajnościowe naszych dwóch bohaterów.

 SVG a canvas - porównanie wydajności

Podsumowanie

Canvas przenosi strony w XXI wiek. Jedyne co nas ogranicza to nasza wyobraźnia (no i oczywiście moc obliczeniowa ;) ), element ten pozwala nam na tworzenie wszystkiego co nam przyjdzie do głowy, a wykorzystanie do tego WebGL wynosi nas na jeszcze wyższy poziom.

Canavs świetnie nadaje się do gier, czego przykładem może być rodzime Skytte lub wyjątkowe Kopanito All Stars Soccer - które są idealnymi przykładami na to jak dobrze można wykorzystać nowe technologie.

Ktoś pomyśli, że napisanie gry musi być bardzo czasochłonne – tak jest, to prawda – ale na szczęście wymyślono coś takiego jak framework, który znacznie przyśpiesza ten proces. Frameworki dzielą się na kilka rodzajów. Te, które korzystają z WebGL (three.js), ze zwykłego 2D API (paper.js) oraz te, które operują tylko na SVG (fabric.js). Oto lista tych popularniejszych:

  • three.js
  • pixie.js, 
  • pharser,  
  • raphael.js
  • paper.js,
  • fabric.js,
  • processing.js,
  • canvasjs

Myślisz o realizacji projektu?

Skontaktuj się z nami. Przygotujemy wycenę, opowiemy o szczegółach i procesie wdrożenia.

Napisz do nas

Strona używa plików cookies. Wyrażasz zgodę na używanie cookies, zgodnie z aktualnymi ustawieniami przeglądarki.