Kawałek Kodu

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!