Gdyby Picasso żył dziś, albo gdyby komputery były szerzej dostępne za jego czasów, to zamiast się męczyć malowałby pewnie zwykłe obrazy, skanował, nakładał filtr pixelize w programie graficznym i drukował. My dziś mamy taką możliwość, więc pobawimy się w malarzy. W tym odcinku każdy będzie Pixasso.
W grupie niewyraźniej.
Z efektem, o którym piszę być może miałeś styczność. Wygląda to tak, że w zależności od mocy filtra grupa pikseli łączy się w jeden duży. Może to być uśredniony kolor z puli lub kolor wybranego. Najłatwiejsze rozwiązanie, to oczywiście wybór koloru z jednego piksela z grupy, najczęściej z lewego górnego rogu. Zdjęcie po takiej operacji zaczyna przypominać bardziej grafikę z czasów kiedy gry komputerowe wczytywało się z kasety magnetofonowej.
Aby osiągnąć taki efekt naprościej będzie skorzystać z elementu CANVAS.
Jak widać na szkicu musimy nasz obraz źródłowy wkleić w mały obszar CANVAS, a następnie ten obszar CANVAS skopiować na całość. Zakładając, że moc naszego efektu ma wartość 2 (czyli zamiast pojedynczego piksela będzie jeden większy o wymiarach 2x2), wklejamy obrazek w połowę obszaru CANVAS, tracimy wtedy co drugi piksel, a następnie wklejony fragment kopiujemy na całość, dzięki czemu każdy piksel powiększy się dwukrotnie. Oczywiście stracimy na jakości - stąd efekt pixelize - bo przecież skrypt nie zgadnie jakie piksele (kolory) straciliśmy przy wklejaniu obrazka w mały obszar. Domyślnie powiększone piksele są rozmyte, bo kontekst CANVAS ma włączoną opcję wygładzania przy renderowaniu. Aby osiągnąć zamierzony efekt należy wyłączyć ją poprzez ustawienie imageSmoothingEnabled na false.
Efekt jak efekt - kiedy jest statyczny, to nic specjalnego. Dużo fajniej wygląda kiedy moc filtra zmienia się w czasie. Widzimy wtedy coraz bardziej spikselowane zdjęcia, albo odwrotnie, jeśli zaczynamy animację od największej mocy.
<canvas width="320" height="320"></canvas>
+
/* początkowy rozmiar grupy pikseli */
let size = 2;
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
/* przygotowujemy nasz obrazek */
const image = new Image();
/* a po załadowaniu możemy
zacząć obsługiwać animację
*/
image.addEventListener("load", function() {
canvas.addEventListener("mouseenter", function() {
size = 2;
requestAnimationFrame(pixelate);
});
});
image.src = "ambulance-2166079_512.jpg";
/* nasza główna funkcja
animująca
*/
function pixelate() {
/* jeśli rozmiar>50,
to kończymy
*/
if (size > 50) {
return;
}
/* obliczamy jaki rozmiar
będzie mieć kopiowany
obrazek
*/
let w = canvas.width / size;
let h = canvas.height / size;
/* kopiujemy obrazek
i wklejamy w obliczony
rozmiar
*/
ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, w, h);
/* skopiowany pomniejszony obrazek
ponownie kopiujemy, ale już z <canvas>
i wklejamy na całość
*/
ctx.drawImage(canvas, 0, 0, w, h, 0, 0, canvas.width, canvas.height);
/* zwiększamy rozmiar */
size += 2;
/* wywołujemy następną klatkę
animacji
*/
requestAnimationFrame(pixelate);
}
Najedź na wnętrze ramki
Wygląda lepiej niż statyczny, ale to nadal nie to. Podczas zmiany mocy filtra można odnieść wrażenie, że piksele ślizgają się po zdjęciu - "jadą" w kierunku prawego dolnego rogu. Dzieje się tak dlatego, ponieważ całe zdjęcie wklejamy poczynając od punktu 0,0 i rozciągamy zawsze w kierunku wspomnianego narożnika.
Jak Światowid.
Właśnie! A gdyby tak każdą ćwiartkę obrazka filtrować z osobna - czyli lewą górną wklejać od środka w kierunku punktu 0,0, prawą górną od środka w kierunku punktu X,0, itd.? Wtedy nasz efekt nie będzie taki mikry, a będzie bardziej przypominał wybuch supernowej, albo powrót z prędkości warp.
O ile rozwiązanie dla prawej dolnej ćwiartki jest identyczne jak powyższy przykład, to jak przekazać do CANVAS informację, aby dolna lewa ćwiartka była skalowana w lewo, a górne ćwiartki w górę?
W przypadku prawej mówimy: wklej obszar poczynając od [szerokosc/2, wysokosc/2], na wymiar [szerokosc/10, wysokosc/10]. Czyli wywołamy metodę drawImage(szerokosc/2, wysokosc/2, szerokosc/10, wysokosc/10). To jest proste, bo pikselowy układ współprzędnych ma wartości X rosnące w prawo, a Y w dół. A jak będzie w przypadku lewej dolnej ćwiartki, którą chcemy wkleić o identycznej skali ale po drugiej stronie osi Y? Skoro podamy za trzeci parametr metody wartość szerokosc/10, to celem będzie prawa dolna ćwiartka, a nie lewa.
Wyłania się negatywny obraz.
Jak się okazuje nic nie stoi na przeszkodzie, aby szerokość i wysokość przekazać jako wartości ujemne. Wydaje się, że nie ma to kompletnie sensu, bo jak wymiar może mieć ujemną wartość? Oczywiście nie będzie mieć, ale tym sposobem informujemy przeglądarkę w jakim kierunku od punktu startowego ma kreślić obszar.
Stwórzmy klasę/plugin do efektu pixelize z założeniem jak powyżej:
class Pixasso {
constructor(DOMImages) {
this.images = [];
this.init(DOMImages);
}
init(DOMImages) {
DOMImages.forEach(
function(DOMImage, index) {
/* tworzymy <canvas>,
ale nie dodajemy
go do drzewa DOM
*/
let canvas = document.createElement("canvas");
canvas.width = DOMImage.width;
canvas.height = DOMImage.height;
let ctx = canvas.getContext("2d");
/* wyłączamy wygładzanie */
ctx.imageSmoothingEnabled = false;
/* tworzymy kopię obrazka,
aby mieć oryginalne piksele,
nie czekamy na load,
bo obrazek wcześniej
się załadował
*/
let imageCopy = new Image();
imageCopy.src = DOMImage.src;
/* zapisujemy indeks w oryginalnym
obrazku, aby mieć odniesienie
do tablicy images
*/
DOMImage.dataset.pixassoIndex = index;
/* tworzymy element w tablicy
images
*/
this.images[index] = {
DOMImage: DOMImage,
DOMImageSrc: DOMImage.src,
canvas: canvas,
ctx: ctx,
imageCopy: imageCopy,
isAnimating: false
};
/* dodajemy obsługę najechania
myszą
*/
DOMImage.addEventListener(
"mouseenter",
function() {
/* ustawiamy flagę startu animacji,
oraz początkową wartość
efektu
*/
this.images[DOMImage.dataset.pixassoIndex].isAnimating = true;
this.images[DOMImage.dataset.pixassoIndex].size = 50;
/* jeśli animacja jeszcze
nie wystartowała,
to uruchamiamy ją
*/
if (!this.raf) {
this.raf = requestAnimationFrame(this.pixelate.bind(this));
}
}.bind(this)
);
}.bind(this)
);
}
pixelate() {
this.images.forEach(
function(img) {
/* sprawdzamy, który
obrazek ma uruchomioną
animację
*/
if (img.isAnimating) {
/* jeśli rozmiar mniejszy od 0,
to animacja się skończyła,
przywracamy oryginalne
źródło
*/
if (img.size < 0) {
img.isAnimating = false;
img.DOMImage.src = img.DOMImageSrc;
} else {
/* jeśli możemy animować,
to powiększamy każdą
ćwiartkę z osobna
*/
let canvas = img.canvas;
let ctx = img.ctx;
let image = img.imageCopy;
/* obliczamy w jaki rozmiar musimy
wkleić skopiowaną ćwiartkę
*/
let w = canvas.width / img.size;
let h = canvas.height / img.size;
ctx.drawImage(
image,
parseInt(image.width / 2),
parseInt(image.height / 2),
parseInt(image.width / 2),
parseInt(image.height / 2),
parseInt(canvas.width / 2),
parseInt(canvas.height / 2),
parseInt(w / 2),
parseInt(h / 2)
);
ctx.drawImage(
canvas,
parseInt(canvas.width / 2),
parseInt(canvas.height / 2),
parseInt(w / 2),
parseInt(h / 2),
parseInt(canvas.width / 2),
parseInt(canvas.height / 2),
parseInt(canvas.width / 2),
parseInt(canvas.height / 2)
);
ctx.drawImage(
image,
parseInt(image.width / 2),
parseInt(image.height / 2),
parseInt(-image.width / 2),
parseInt(image.height / 2),
parseInt(canvas.width / 2),
parseInt(canvas.height / 2),
parseInt(-w / 2),
parseInt(h / 2)
);
ctx.drawImage(
canvas,
parseInt(canvas.width / 2),
parseInt(canvas.height / 2),
parseInt(-w / 2),
parseInt(h / 2),
parseInt(canvas.width / 2),
parseInt(canvas.height / 2),
parseInt(-canvas.width / 2),
parseInt(canvas.height / 2)
);
ctx.drawImage(
image,
parseInt(image.width / 2),
parseInt(image.height / 2),
parseInt(-image.width / 2),
parseInt(-image.height / 2),
parseInt(canvas.width / 2),
parseInt(canvas.height / 2),
parseInt(-w / 2),
parseInt(-h / 2)
);
ctx.drawImage(
canvas,
parseInt(canvas.width / 2),
parseInt(canvas.height / 2),
parseInt(-w / 2),
parseInt(-h / 2),
parseInt(canvas.width / 2),
parseInt(canvas.height / 2),
parseInt(-canvas.width / 2),
parseInt(-canvas.height / 2)
);
ctx.drawImage(
image,
parseInt(image.width / 2),
parseInt(image.height / 2),
parseInt(image.width / 2),
parseInt(-image.height / 2),
parseInt(canvas.width / 2),
parseInt(canvas.height / 2),
parseInt(w / 2),
parseInt(-h / 2)
);
ctx.drawImage(
canvas,
parseInt(canvas.width / 2),
parseInt(canvas.height / 2),
parseInt(w / 2),
parseInt(-h / 2),
parseInt(canvas.width / 2),
parseInt(canvas.height / 2),
parseInt(canvas.width / 2),
parseInt(-canvas.height / 2)
);
img.DOMImage.src = canvas.toDataURL();
}
/* zmniejszamy rozmiar */
img.size -= 2;
}
}.bind(this)
);
/* sprawdzamy czy jakikolwiek
obrazek jest animowany
*/
let isAnyAnimating = this.images.some(function(img) {
return img.isAnimating;
});
/* jeśli jest, to uruchamiamy
kolejną klatkę animacji
*/
if (isAnyAnimating) {
requestAnimationFrame(this.pixelate.bind(this));
} else {
/* jeśli nie, to resetujemy
ID wywołania animacji,
będziemy wiedzieć, że nie działa
i na hover można ją uruchomić
ponownie
*/
this.raf = false;
}
}
}
/* inicjujemy klasę po załadowaniu
wszystkich obrazków
*/
window.addEventListener("load", function() {
new Pixasso(document.querySelectorAll("img"));
});
Jakie tu mamy różnice w stosunku do poprzedniego efektu? Przede wszystkich startujemy od największej siły efektu. Kiedy rozmiar osiągnie wartość 0, to przywracamy oryginalne źródło obrazka. Być może to jest "przede wszystkim", bowiem nie podmieniamy obrazków na stronie wstawiając zamiast nich CANVAS, ale po prostu ustawiamy im źródło w postaci base64. Nie ingerujemy kompletnie w strukturę drzewa DOM, więc nie musimy się martwić o nadanie takich samych styli dla podstawianego CANVAS, nie musimy się martwić o skalowanie, zdarzenia przypięte do obrazka czy "popsucie" drzewa DOM. Modyfikujemy po prostu oryginalny obrazek, a jeszcze lepsze jest to, że może on być responsywny.
I docelowy efekt animowanego pixelize.
Nie zmaluj niczego do następnego wpisu. Do przeczytania!