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", () => {
//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", () => {
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", () => {
ctx.drawImage(img, 0, 0);
});
ctx.drawImage(img, 0, 0);
}
img.addEventListener("load", () => {
init();
});
img.src = "./image.png";
Najedź kursorem na grafikę:
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 czyliimageData.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">
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.