Canvas animacje

Aby animować cokolwiek na canvasie musimy zastosować podejście znane chociażby z gier. Robimy więc główną pętlę (intervał), w której każdorazowo czyścimy cały canvas, rysujemy daną klatkę animacji, po czym powtarzamy rysując kolejną. I tak w koło Macieju...

Aby animować, musimy nasz kod rysujący wywoływać cyklicznie. Możemy do tego wykorzystać funkcję setInterval():


function draw() {
    ...kod rysujący
}


setInterval(draw, 1000/60);

lub funkcję setTimeout:


function draw() {
    ...kod rysujący

    setTimeout(draw, 1000/60);
}


setTimeout(draw, 1000/60);

Problem z zastosowaniem tych metod w przypadku animacji jest taki, że nie mamy pewności, czy rysowanie pojedynczej klatki zakończy się na czas. Może to powodować, że odpalony interwał zacznie się dławić, a nasza animacja nie będzie należycie płynna. Moglibyśmy to próbować obchodzić przez samo wywołujący się setTimeout, ale wtedy nasza animacja mogła by nie być optymalnie płynna.

Jeżeli zależy nam na tym, by nasza animacja działała optymalnie, wtedy do takiego cyklicznego wywoływania kodu powinniśmy użyć metody requestAnimationFrame(fn). Metoda ta wywoła dany kod przed kolejną klatką rysowania strony. Dzięki temu przeglądarka optymalnie dobierze moment kiedy taki kod ma być odpalony, a nasza animacja będzie płynniejsza.


function draw() {
    ...kod rysujący

    requestAnimationFrame(draw);
}

requestAnimationFrame(draw);

Moment taki zależy od kilku czynników. Obciążenie procesora czy chociażby to, czy nasza strona jest w aktywnej zakładce, czy działa gdzieś w tle. Jeżeli w tle - wtedy animacja nie musi być odgrywana.

Każde użycie requestAnimationFrame zwraca ID, które możemy wykorzystać do przerwania tak trwającej animacji:

Aby zatrzymać tak odpaloną animację skorzystamy z metody cancelAnimationFrame(id):


let requestId;

function loop() {
    requestId = requestAnimationFrame(loop);
    ...
}

function start() {
    requestId = requestAnimationFrame(loop);
}

function pause() {
    cancelAnimationFrame(requestId)
}

Niestety w przeciwieństwie do setInterval nie podajemy tutaj szybkości działania takiej animacji. Częstotliwość wykonywania funkcji wynosi zazwyczaj ok 60 razy na sekundę, jednakże według rekomendacji W3C w większości przeglądarek odpowiada częstotliwości odświeżania ekranu. Przy monitorach z dużą częstotliością oznacza to, że nasz kod będzie odpalany np. 120 razy na sekundę. W większości sytuacji sprawi to, że nasza animacja albo będzie super szybka, albo będzie klatkować, bo komputer nie wyrobi obliczeń.

W moich prywatnych tworach zauważyłem, że w Chromie "efekt" ten nie był tak bardzo zauważalny jak w przypadku Firefoxa, w którym użycie "czystego" requestAnimationFrame najczęściej sprawiało dławienie się animacji.

Aby to naprawić możemy skorzystać z przepisu np. z tej strony (chociaż tak naprawdę wystarczy w google wpisać requestAnimationFrame control frames). Metoda często używana jest w grach, gdzie chcemy uniezależnić naszą animację od sztywnych fps (których komputer może nie zdążyć wyświetlić), a chcemy bazować na czasie delta, który oznacza czas jaki minął między dwoma klatkami.

Prosta implementacja takiej funkcji może mieć postać:


function animateFps(cb, fps = 60) {
    let now;
    let then = Date.now();
    let delta = 0;
    let interval = 1000 / fps;

    const update = () => {
        requestAnimationFrame(update);
        now = Date.now();
        delta = now - then;

        if (delta > interval) {
            cb(); //funkcja która będzie cyklicznie odpalana
            console.log(now, delta ,interval )
            then = now - (delta % interval);
        }
    }

    update();
}


animateFps(() => {
    console.log("animacja :)");
}, 24);

I użyjmy, na razie do wypisywania tekstu:


let count = 0;

function draw() {
    count++;
    divText.innerText = "Animujemy " + count;
}

const btnStart = document.querySelector("#animStart");
const divText = document.querySelector("#testController");

btnStart.addEventListener("click", e => {
    animateFps(() => draw(), 30);
    btnStart.disabled = true;
});

Mając już podstawowy kontroler do obsługi głównej pętli animacji możemy przejść do sedna rozdziału - animowania.

Animowanie pojedynczego obiektu

Do czyszczenia klatki wykorzystamy znaną nam metodę clearRect(), którą będziemy czyścić całą zawartość całego canvasa.


function draw() {
    //czyścimy obszar canvasu
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    //rysujemy
    ...
}

const animLoop = new AnimationFrame(30, () => draw());

Przypuśćmy, że chcemy animować odbijającą się od krawędzi gwiazdkę. Wpierw musimy wczytać grafikę gwiazdki:


function draw() {
    ...
}

const image = new Image(24, 24); //grafika ma rozmiary 24x24
image.addEventListener("load", e => {
    animateFps(() => draw(), 30);
});
image.src = "./gwiazdka.png";

Następnie dla naszej gwiazdki musimy stworzyć zmienne dla jej pozycji. W każdej klatce animacji będziemy zmieniać jej pozycję oraz wyliczać czy gwiazdka nie dotknęła krawędzi canvas. Jeżeli dotknęła, wtedy kierunek ruchu na danej osi (x lub y) zmieniamy na odwrotny:


const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

const star = {
    x : 0, //x i y gwiazdki
    y : 0,
    dirX : 1, //kierunek ruchu
    dirY : 1,
    speed : 2 //szybkość ruchu
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    //jeżeli gwiazdka dotknie krawędzi canvasu, zmieniamy jej kierunek na przeciwny
    //x jest liczony od lewej strony, dlatego dla prawej i dolnej krawędzi musimy odejmować wymiary obrazka (24x24)
    if (star.x < 0 || star.x > canvas.width - 24) {
        star.dirX = -star.dirX;
    }

    if (star.y < 0 || star.y > canvas.height - 24) {
        star.dirY = -star.dirY;
    }

    //do x i y gwiazdki dodajemy przesuniecie
    if (star.x < 0) { star.dirX = 1 }
    if (star.x + 24 > canvas.width) { star.dirX = -1 }
    if (star.y < 0) { star.dirY = 1 }
    if (star.y + 24 > canvas.height) { star.dirY = -1 }

    //rysujemy gwiazdkę w nowym miejscu
    ctx.drawImage(image, star.x, star.y);
}

const image = new Image(24, 24);
image.addEventListener("load", e => {
    animateFps(() => draw(), 30);
});
image.src = "gwiazdka.png";

Animowanie kilku obiektów

Dla większej liczby obiektów będziemy musieli nieco zmodyfikować nasz kod.
Wszystkie gwiazdki będziemy trzymać w tablicy. Podczas pojedynczej klatki zrobimy pętlę po tej tablicy, obliczymy nową pozycję dla każdej gwiazdki i narysujemy każdy obiekt.

Do generowania randomowych liczb użyjemy funkcji, którą napisaliśmy w poprzednim rozdziale.

const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

const starsCount = 20; //liczba gwiazdek do animowania
const starsList = []; //tablica przechowująca gwiazdki

const rand = function(min, max) {
    return Math.floor(Math.random()*(max-min+1)+min);
}

//tworzymy nowe gwiazdki i wrzucamy je do tablicy
for (let i=0; i<starsCount; i++) {
    const star = {
        x : rand(0, canvas.width - 40),
        y : rand(0, canvas.width - 40),
        dirX : (Math.random() > 0.5) ? 1 : -1,
        dirY : (Math.random() > 0.5) ? 1 : -1,
        speed : rand(1, 2)
    }
    starsList.push(star);
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    for (const el of starsList) {
        el.x += el.speed * el.dirX;
        el.y += el.speed * el.dirY;

        if (el.x < 0) { el.dirX = 1 }
        if (el.x + 24 > canvas.width) { el.dirX = -1 }
        if (el.y < 0) { el.dirY = 1 }
        if (el.y + 24 > canvas.height) { el.dirY = -1 }

        ctx.drawImage(image, el.x, el.y);
    }
}

const image = new Image(24, 24); //obrazek ma wymiary 24,24
image.addEventListener("load", e => {
    animateFps(() => draw(), 30);
});
image.src = "gwiazdka.png";

Animacja poklatkowa

Jak już wiemy, metoda drawImage w postaci drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight) służy do rysowania na płótnie wyciętego kawałka grafiki. Metoda ta idealnie się nadaje do stworzenia poklatkowej animacji, która polega na wyświetlaniu kolejnych klatek animacji, które są ułożone obok siebie na jednej grafice z przezroczystym tłem.

W internecie jest bardzo dużo grafik typu "sprites", które są "wyciągnięte" ze starych gier 2d. Wystarczy w Google poszukać sformułowań typu "game sprites", by dostać ich pokaźną kolekcję. Przykładowe linki:
http://www.spriters-resource.com/
http://sdb.drshnaps.com/

W poniższym przykładzie skorzystamy z poniższego sprite:

Scott Pilgrim

Aby teraz animować taką grafikę, powinniśmy kolejno wyświetlać jej wycięte fragmenty. Aby to zrobić, powinniśmy znać numer wyświetlanej klatki, i za pomocą tej liczby obliczać w każdej klatce animacji przesunięcie wycinania.


let currentFrame = 0; //aktualna klatka
let framesMax = 8; //max klatek
let frameWidth = 108; //szerokość 1 klatki
let frameHeight = 120; //wysokość klatki

const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    if (currentFrame > framesMax - 1) {
        currentFrame = 0;
    }
    currentFrame++;

    const cutX = (currentFrame - 1) * frameWidth;
    const drawPosX = canvas.width / 2 - 60; //polowa canvasu minus polowa klatki
    const drawPosY = canvas.height - frameHeight; //chcemy by Pilgrim biegał na dole canvasu

    ctx.drawImage(img, cutX, 0, frameWidth, frameHeight, drawPosX, drawPosY, frameWidth, frameHeight);
}

const img = new Image();
img.src = "./scottpilgrim_multiple.png";

img.addEventListener("load", e => {
    animateFps(() => draw(), 30);
});

W ramach treningu możemy też dodać do naszej animacji kreski symbolizujące pęd. Podobnie jak w przypadku gwiazdek - możemy tutaj użyć tablicy, w której będziemy przechowywać każdą kreskę:


let currentFrame = 0;
let framesMax = 8;
let frameWidth = 108;
let frameHeight = 120;
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

const lines = [];
for (let i=0; i<50; i++) {
    const line = {
        x : rand(-100, canvas.width),
        y : rand(-100, canvas.height),
        speed : 40,
        length : rand(50, 250)
    }
    lines.push(line)
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    if (currentFrame > framesMax - 1) {
        currentFrame = 0;
    }
    currentFrame++;

    for (const el of lines) {
        el.x -= el.speed;
        if (el.x <= -el.length) {
            el.x = canvas.width;
        }
        ctx.beginPath();
        ctx.strokeStyle = "rgba(0,0,0,0.2)"
        ctx.lineWidth = 0.5;
        ctx.moveTo(el.x, el.y);
        ctx.lineTo(el.x + el.length, el.y);
        ctx.stroke();
    }

    const cutX = (currentFrame - 1) * frameWidth;
    const drawPosX = canvas.width / 2 - 60; //polowa canvasu minus polowa klatki
    const drawPosY = canvas.height - frameHeight; //chcemy by Pilgrim biegał na dole canvasu

    ctx.drawImage(img, cutX, 0, frameWidth, frameHeight, drawPosX, drawPosY, frameWidth, frameHeight);
}

const img = new Image();
img.src = "./scottpilgrim_multiple.png";

img.addEventListener("load", e => {
    animateFps(() => draw(), 30);
});

Hola, hola! Podobne rzeczy jeszcze łatwiej zrobisz w czystym CSS. Przeczytaj sobie moje artykuły na ten temat:

animacje css i baner animowany

Jedno jednak drugiego nie wyklucza. Powyższe podejście bardziej się sprawdzi przy grach, gdzie musimy reagować na poczynania gracza...

Wszelkie prawa zastrzeżone. Jeżeli chcesz używać jakiejś części tego kursu, skontaktuj się z autorem.
Aha - i ta strona korzysta z ciasteczek.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.