Canvas grafika

Funkcja drawImage()

Do rysowania danej grafiki na płótnie służy funkcja drawImage(), która występuje w 3 wariantach.


ctx.drawImage(image, dx, dy);
ctx.drawImage(image, dx, dy, dWidth, dHeight);
ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
image grafika, którą będziemy rysować na płótnie. Może to być obiekt Image(), pobrany ze strony jakiś element img, czy pojedyncza klatka video.
dx, dy pozycja na płótnie gdzie będziemy rysować
dWidth, dHeight rozmiary rysowanej grafiki na płótnie
sx, sy pozycja pobieranego wycinka na grafice źródłowej
sWidth, sHeight rozmiary pobieranego wycinka z grafiki źródłowej

Kilka przykładów


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

image.addEventListener("load", e => {
    //proste rysowanie
    ctx.drawImage(image, 0, 0);

    //rysowanie z przeskalowaniem
    ctx.drawImage(image, 40, 40, canvas.width - 80, canvas.height - 80);

    //obwódka dla czytelności
    ctx.rect(40, 40, canvas.width - 80, canvas.height - 80);
    ctx.stroke();
});

image.src = "image.png";


const canvas = demo.querySelector("#canvasCroopA");
const ctx = canvas.getContext("2d");
const img = new Image();

img.addEventListener("load", e => {
    ctx.drawImage(
        img,
        148, 52, 143, 143, //x, y, w, h - skąd pobieram
        10, 10, 280, 280 //x, y, w, h - gdzie rysuję
    );
});

img.src = "./image.png";


const demo = document.querySelector("#demoScale");
const canvas = demo.querySelector("canvas");
const ctx = canvas.getContext("2d");
const img = demo.querySelector("img");

function clamp(nr, min, max) {
    return Math.min(Math.max(nr, min), max);
}

function init() {
    img.addEventListener("mousemove", e => {
        const size = 80;

        let x = e.offsetX - size / 2;
        x = clamp(x, 0, canvas.width - size);

        let y = e.offsetY - size / 2;
        y = clamp(y, 0, canvas.height - size);

        ctx.drawImage(img, x, y, size, size, 0 , 0, canvas.width, canvas.height);
    });

    img.addEventListener("mouseout", e => {
        ctx.drawImage(img, 0, 0);
    });

    ctx.drawImage(img, 0, 0);
}

img.addEventListener("load", e => {
    init();
});

img.src = "./image.png";

Najedź kursorem na grafikę:

grafika

Przy omawianiu tablic wielowymiarowych pokusiliśmy się o wygenerowanie planszy. Za pomocą powyższych informacji moglibyśmy wreszcie wykonać je jak należy.

Do poniższego skryptu użyję tej grafiki.


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

//by bylo ładniej trochę zmieniłem indeksy w stosunku do kodu z tamtego zadania
const level = [
    [16, 16, 5, 5, 2, 2, 5, 5, 16, 16],
    [16, 5, 5, 5, 2, 2, 5, 5, 5, 16],
    [16, 5, 16, 16, 2, 2, 16, 16, 5, 16],
    [16, 5, 2, 2, 2, 2, 2, 2, 5, 16],
    [2, 2, 2, 2, 16, 16, 2, 2, 2, 2],
    [2, 2, 2, 2, 16, 16, 2, 2, 2, 2],
    [16, 5, 2, 2, 2, 2, 2, 2, 5, 16],
    [16, 5, 16, 16, 2, 2, 16, 16, 5, 16],
    [16, 5, 5, 5, 2, 2, 5, 5, 5, 16],
    [16, 16, 5, 5, 2, 2, 5, 5, 16, 16]
];

//funkcja rysująca
//ctx - canvas
//x, y - pozycja rysowania
//size - rozmiar rysowanego na planszy kafelka
//tileNr - numer kafelka liczony od 1
function paint(ctx, x, y, size, tileNr) {
    const sourceSize = 48;
    const sourceX = Math.floor((tileNr - 1) % 7) * sourceSize;
    const sourceY = Math.floor((tileNr - 1) / 7) * sourceSize;
    ctx.drawImage(
        img,
        sourceX, sourceY, sourceSize, sourceSize,
        size * x, size * y, size, size
    );
}

function drawLevel() {
    for (let y=0; y<level.length; y++) {
        const sub = level[y];

        for (let x=0; x<sub.length; x++) {
            paint(ctx, x, y, 50, sub[x]);
        }
    }
}

const img = new Image();
img.addEventListener("load", e => {
    drawLevel();
});
img.src = "sprite-tiles.png";

Pattern

Podobnie jak przy tle w CSS, także dla canvas możemy użyć powtarzania tła. Służy do tego funkcja createPattern(img, powtarzanie). Funkcja ta przyjmuje parametry:

img wczytana wcześniej grafika
repeat sposób powtarzania. Przyjmuje jedną z wartości: repeat, repeat-x, repeat-y i no-repeat

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

const img = new Image();

img.addEventListener("load", e => {
    ctx.fillStyle = ctx.createPattern(img, "repeat");
    ctx.fillRect(40, 20, 150, 120);
    ctx.strokeRect(40, 20, 150, 120);
});

img.src = "./happy.png";

Zauważ, że nasz wzorek zaczyna się krzywo. Podobnie do transformacji aby wzór rozpoczynał się wraz z figurą, musimy posłużyć się funkcją translate:


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

const img = new Image();

img.addEventListener("load", e => {
    ctx.save();
    ctx.translate(40, 20);
    ctx.fillStyle = ctx.createPattern(img, "repeat");
    ctx.fillRect(0, 0, 150, 120); //zmieniamy pozycję rysowanej figury na 0,0
    ctx.strokeRect(0, 0, 150, 120);
    ctx.restore();
});

img.src = "./happy.png";

Podobnie będzie przy pozostałych typach powtórzeń:


ctx.save();
ctx.translate(40, 20);
ctx.fillStyle = ctx.createPattern(img, "repeat");
ctx.fillRect(0, 0, 150, 120);
ctx.strokeRect(0, 0, 150, 120);
ctx.restore();

ctx.save();
ctx.translate(200, 20);
ctx.fillStyle = ctx.createPattern(img, "repeat-x");
ctx.fillRect(0, 0, 150, 120);
ctx.strokeRect(0, 0, 150, 120);
ctx.restore();

ctx.save();
ctx.translate(360, 20);
ctx.fillStyle = ctx.createPattern(img, "repeat-y");
ctx.fillRect(0, 0, 150, 120);
ctx.strokeRect(0, 0, 150, 120);
ctx.restore();

ctx.save();
ctx.translate(520, 20);
ctx.fillStyle = ctx.createPattern(img, "no-repeat");
ctx.fillRect(0, 0, 150, 120);
ctx.strokeRect(0, 0, 150, 120);
ctx.restore();

Manipulacja pikselami

Każda grafika rastrowa z canvasem włącznie to uporządkowany zbiór pikseli. Lewy górny róg to początek, a dolny prawy do koniec.

Aby manipulować poszczególnymi pikselami wykorzystamy do tego obiekt typu ImageData. Obiekt taki jest "zapisem grafiki" i zawiera 3 właściwości:

width, height rozmiary grafiki
imageData.data zwraca 1 wymiarową tablicę, której poszczególnymi wartościami są składowe rgba kolejnych pikseli czyli
imageData.data = [r,g,b,a,  r,g,b,a,  r,g,b,a, ....].
Zwracana tablica jest wielkości szerokośćCanvas * wysokośćCanvas * 4 a każda jej komórka zawiera wartość z przedziału 0-255

Element canvas udostępnia nam też metody, dzięki którym możemy obsłużyć piksele:

context.createImageData(width, height)tworzy pusty obiekt typu ImageData o wymiarach podanych w parametrach. Wszystkie piksele zwróconego obiektu są przezroczyste
context.createImageData(innyImageData)zwraca obiekt typu ImageData o wymiarach takich samych jakie ma obiekt przekazany w parametrze. Tylko wymiary są kopiowane. Dane o pikselach nie są kopiowane.
context.getImageData(x, y, width, height)pobiera obiekt typu ImageData, który jest wycinkiem canvasu o wymiarach podanych w atrybutach. Jeżeli x i y nie są podane, wtedy przyjmują wartości 0
context.putImageData(ImageData, x, y)rysuje na canvasie w pozycji x,y piksele z imageData.

Przykładowe manipulacje

Mając powyższe informacje, możemy pokusić się o przeprowadzenie prostych manipulacji na naszych grafikach. Jedną z nich jest chociażby odwracanie kolorów. Z powyższych informacji wiemy, że tablica imageData.data zawiera informacje o składowych r,g,b,a (w skali 0-255) kolejnych pikseli.


function invertImage(imgData) {
    const d = imgData.data;
    for (let i=0; i<d.length; i+=4) {
        d[i] = 255 - d[i];
        d[i+1] = 255 - d[i+1];
        d[i+2] = 255 - d[i+2];
    }
    return imgData;
}

const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
const img = new Image(300, 300);

img.addEventListener("load", e => {
    ctx.drawImage(img, 0, 0); //rysujemy oryginalną grafikę

    //pobieramy dane z płótna
    const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    const newData = invertImage(imgData);

    //rysujemy na canvasie w pozycji x 300 nowe dane
    ctx.putImageData(newData, 300, 0);
});

img.src = "image.png";

W podobieństwie do powyższej funkcji możemy równie łatwo wykonać inne manipulacje kolorami:


//wyszarzanie grafiki
function desaturateImage(imgData) {
    const d = imgData.data;
    for (let i=0; i<d.length; i+=4) {
        //ujednolicam kolory - wyliczając średnią
        const average = (d[i] + d[i+1] + d[i+2]) / 3;
        d[i] = d[i+1] = d[i+2] = average;
    }
    return imgData;
}

//rozjaśnianie kolorów
function brightnessImage(imgData, brightness) {
    const d = imgData.data;
    for (let i=0; i<d.length; i+=4) {
        d[i] += brightness;
        d[i+1] += brightness;
        d[i+2] += brightness;
    }
    return imgData;
}

//zmiana kontrastu
//kontrast z zakresu -100 : 100
function contrastImage(imgData, contrast) {
    const d = imgData.data;
    contrast = (contrast / 100) + 1;  //convert to decimal & shift range: [0..2]
    const intercept = 128 * (1 - contrast);
    for(let i=0; i<d.length; i+=4){   //r,g,b,a
        d[i] = d[i] * contrast + intercept;
        d[i+1] = d[i+1] * contrast + intercept;
        d[i+2] = d[i+2] * contrast + intercept;
    }
    return imgData;
}

const canvas = document.querySelector("#manipulationDemo canvas");
const ctx = canvas.getContext("2d");
const img = new Image();

const desaturateCheckbox = document.querySelector("#desaturateCheckbox");
const brightnessRange = document.querySelector("#brightnessRange");
const contrastRange = document.querySelector("#contrastRange");

function drawImage() {
    ctx.drawImage(img, 0, 0);

    let imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    if (desaturateCheckbox.checked) imgData = desaturateImage(imgData);
    imgData = brightnessImage(imgData, +brightnessRange.value);
    imgData = contrastImage(imgData, +contrastRange.value)
    ctx.putImageData(imgData, 300, 0);
}

img.addEventListener("load", e => {
    [desaturateCheckbox, brightnessRange, contrastRange].forEach(el => {
        el.addEventListener("input", drawImage);
    })

    drawImage();
});

img.src = "image.png";

Generowanie na bazie płótna

Podejrzewam, że jeszcze nie do końca dostrzegasz olbrzymich mocy, jakie drzemią w powyższych funkcjach. Jeżeli na dane płótno wrzucisz jakąkolwiek grafikę, możesz następnie pobrać te dane i na ich podstawie robić dowolne efekty na stronie. Wrzucana grafika może pochodzić z wielu miejsc. Możesz więc ją rysować za pomocą odpowiednich funkcji, możesz ją pobrać za pomocą querySelector, możesz stworzyć grafikę i podać jej jakiś zewnętrzny adres, czy też pobrać ją bezpośrednio z video lub kamery internetowej.

Gdy taką grafikę wrzucisz na płótno o małych rozmiarach, a następnie pobierzesz z niego informacje o pikselkach, otrzymasz porcję danych idealnie nadającą się do generowania różnych efektów.

Sprawdźmy to. Powiedzmy, że mamy na stronie grafikę:


<img src="./kwiat.png" id="fleurImg" alt="kwiat" width="530" height="530">
kwiat

Na jej bazie chcemy wygenerować sobie nowy element składający się z kolorowych divów, gdzie każdy div będzie odpowiadał danemu pikselowi. Powyższa grafika ma rozmiar 530x530 co zmusiło by nas do wygenerowania 530 x 530 = 2809000 nowych divów. Dużo za dużo. Wrzućmy ją więc na jakiś mniejszy canvas:


<canvas width="80" height="80" id="fleurCanvasSmall"></canvas>

const img = document.querySelector("#fleurImg");
const canvas = document.querySelector("#fleurCanvasSmall");
const ctx = canvas.getContext("2d");

img.addEventListener("load", e => {
    ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
});
if (img.complete) img.dispatchEvent(new Event("load"));

Stwórzmy teraz element, w którym będziemy tworzyć divy oraz funkcję generującą odpowiedni html:


<style>
#result {
    gap: 1px;
    display: grid;
    width: 800px;
    height: 800px;
    border: 1px solid var(--border-color);
}
#result .el {
    border-radius: 50%;
}
</style>

<div id="result"></div>

//w sumie to samo co powyżej omawiane funkcje modyfikujące
function generate(imageData) {
    const d = imageData.data;
    let str = "";

    for(let i=0; i<d.length; i+=4) {
        const r = d[i];
        const g = d[i+1];
        const b = d[i+2];
        str += `<i class="el" style="background: rgb(${r}, ${g}, ${b})"></i>`;
    }

    result.style.gridTemplateColumns = `repeat(${imageData.width}, 1fr)`;
    result.style.gridTemplateRows = `repeat(${imageData.height}, 1fr)`;
    result.innerHTML = str;
}

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

img.addEventListener("load", e => {
    ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    generate(imageData);
});
if (img.complete) img.dispatchEvent(new Event("load"));

I tyle. Osobiście raczej nie polecam tworzyć takiej dużej liczby divów, a zamiast tego generować odpowiedni obraz na canvas, albo wygenerować wartość odpowiedniej właściwości (np. background, czy box-shadow).


Możemy też pokusić się o pójście o krok dalej, i wrzucać na canvas grafikę pobieraną bezpośrednio z input:file. W poniższym przykładzie dodatkowo przed wrzuceniem lekko przekształcę wynik korzystając z wcześniejszych funkcji:


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

const inputFile = document.querySelector("#file");
inputFile.addEventListener("change", e => {
    const reader = new FileReader();
    reader.onload = e => {
        const img = new Image();
        img.onload = e => {
            ctx.drawImage(img, 0, 0, 100, 100);
            let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
            imageData = desaturateImage(imageData);
            generate(imageData);
        }
        img.src = reader.result;
    };
    reader.readAsDataURL(e.target.files[0]);

})

Zamiast pojedynczych grafik możesz też generować całe animacje. Wystarczy płynnie pobierać kolejne klatki video, a następnie wrzucać je na płótno. W kolejnym rozdziale zobaczysz, że do takich rzeczy idealnie się nadaje funkcja requestFrameRate():


    <video width="800" height="600"></video>
    <canvas width="400" height="200"></canvas>

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

let play = false;

function draw() {
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

    //i zacząć nimi dowolnie manipulować
    generate(imageData);

    if (play) {
        requestFrameRate(draw);
    }
}

video.addEventListener("play", e => {
    play = true;
})

video.addEventListener("pause", e => {
    play = false;
})

A możliwości stają się jeszcze większe, gdy dla takiego video będziesz pobierał obraz z kamerki (część poniższego kodu skopiowałem z tamtej strony).


<button id="startRecord" class="button">Rozpocznij przechwytywanie</button>
<video id="video" width="400" height="400" autoplay playsinline></video>
<canvas id="canvasVideo" width="80" height="80"></canvas>
<div class="result" id="videoResult"></div>

const video = document.querySelector("#video");
const canvas = document.querySelector("#canvasVideo");
const ctx = canvas.getContext("2d");
const result = document.querySelector("#videoResult");

function generate(imageData) {
    const d = imageData.data;
    let str = "";
    for(let i=0; i<d.length; i+=4) {
        const r = d[i];
        const g = d[i+1];
        const b = d[i+2];
        str += `<i class="el" style="background: rgb(${r}, ${g}, ${b})"></i>`;
    }
    result.style.gridTemplateColumns = `repeat(${imageData.width}, 1fr)`;
    result.style.gridTemplateRows = `repeat(${imageData.height}, 1fr)`;
    result.innerHTML = str;
}

function draw() {
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    generate(imageData);

    requestAnimationFrame(draw);
}

function handleSuccess(stream) {
    video.srcObject = stream;
    draw();
}

function handleError(error) {
    if (error.name === 'OverconstrainedError') {
        const v = constraints.video;
        console.error(`The resolution ${v.width.exact}x${v.height.exact} px is not supported by your device.`);
    } else if (error.name === 'NotAllowedError') {
        console.error(`Permissions have not been granted to use your camera and microphone, you need to allow the page access to your devices in order for the demo to work.`);
    }
    console.log(error.name);
    console.error(`getUserMedia error: ${error.name}`, error);
}

async function init() {
    try {
        const constraints = {
            audio: false,
            video: true
        };

        const stream = await navigator.mediaDevices.getUserMedia(constraints);
        handleSuccess(stream);
    } catch (e) {
        handleError(e);
    }
}

const btn = document.querySelector("#startRecord");
btn.addEventListener("click", init)

Na koniec przydało by się sam canvas i video ukryć dowolną techniką, a i odejście od generowania divów na rzecz czegoś innego na pewno mocno by poprawiło wydajność (generowanie tekstu, generowanie box-shadow, może rysowanie po canvas?). Swego czasu podobną techniką zrobiłem mini eksperyment związany z Bad Apple, gdzie właśnie korzystam z box-shadow.

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.