Nie, nie, oczywiście nie będę namawiał żadnego z Was do uzyskiwania efektu guza na własnej głowie. Czasy biegania po pokoju i obijania się o wszelkie narożniki i ściany minęły. Ale ponieważ w duszy każdego z nas jest odrobinę dziecka, to będziemy nabijać inne guzy. Będzie to też nawiązanie do pierwszych, bardziej zaawansowanych gier 3D, w który efekt ten mogliśmy zaobserwować. Wywoływał wtedy rumieńce na buzi, ale bez guzów!
Skąd się więc biorą guzy?
Są dwie metody uzyskiwania bumpmuppingu. Jedna z nich, bardziej zaawansowana, polega na tym, że musimy posiadać dwie bitmapy aby uzyskać efekt. Jedna to oczywiście bitmapa, którą będziemy poddawać efektowi, druga stanowi tzw. mapę zniekształceń. Jest to bitmapa w odcieniach szarości gdzie jasność piksela odpowiada za wysokość oczekiwanego zniekształcenia - biały kolor najwyższy punkt, czarny najniższy. Mapa zniekształceń może, ale nie musi być niezależna od bitmapy źródłowej. Bitmapę źródłową może stanowić zapisana kartka papieru, a mapę zniekształceń będą stanowić biało czarne plamy. Obliczając wektor normalny do punktu na mapie zniekształceń (wektoro normalny, to wektor prostopadły do danego punktu) możemy się dowiedzieć jak "pochyły" jest punkt w stosunku do płaskiej powierzchni. Mając wektor normalny i wektor światła możemy określić jak światło opływa dany punkt (czy świeci z góry - jasno, czy z boku - ciemniej). W tym przypadku możemy uzyskac tekstury prezentującej pogniecioną kartkę. Metodę tą można uprościć, tj. kroki liczenia wektora normalnego i światła oraz ich interakcji. Ale o tym przy okazji drugiej metody.
Ta druga metoda nie korzysta z mapy zniekształceń. Tą obliczamy z samego obrazu źródłowego. Wadą jest oczywiście to, że np. mając wspomnianą kartę papieru nie poddamy jej efektowi pogniecenia, bo z niej samej nie uzyskamy takiej mapy zniekształceń. Mamy tu zazwyczaj dużo białej powierzchni i trochę ciemnych pikseli. Zniekształcenia będą występować tu głównie na granicach tych obszarów. Tak więc efekt jaki możemy uzyskać, to raczej coś w rodzaju wygrawerowanego czy wytłoczonego pisma - jasny kolor górka, ciemny kolor (pismo) dołek. Zbocza będą stanowić granice kolorów ciemnego i jasnego. Wszystko tu zależy od bitmapy wejściowej. Jeśli będzie prezentować owoc pomarańcza, to i końcowy efekt może być zadowalający. W przypadku obrazu kolorowego zamieniamy go po prostu na czarno-biały, ale tylko na potrzebę obliczenia mapy zniekształceń. Efekt końcowy oczywiście możemy zastosować do wejściowego obrazu kolorowego.
No dobra, ale skoro wspomniałem, że nie trzeba liczyć wektora normalnego oraz światła, to najwyższy czas oświecić nas jak to zrobić. Możemy tu zastosować uproszczenie, o którym zresztą już wspomniałem - może trochę w zaciemniony sposób, ale jednak. Mianowicie stosując założenie, że ciemny punkt jest najniżej, a jasny najwyżej, możemy obliczyć nachylenie powierzchni w danym punkcie jako różnicę jasności między nim, a sąsiadem. Jeśli obydwa będą ciemne, albo obydwa jasne, to mamy płaską powierzchnię, jeśli badany jest jasny, a kolejny ciemny, to znaczy, że mamy spadek, jeśli odwrotnie - górkę. W ten sposób obliczamy mapę zniekształceń.
A co ze światłem? Do światła zastosujemy gotową bitmapę. W środku jasne punkt, a zbliżając się do krawędzi ciemne - radialny gradient.
Jak to teraz połączyć. Na początek załóżmy, że bitmapa oraz tekstura ze światłem są identycznych wymiarów i światło świeci centralnie na środek bitmapy. Tak więc punkty 0,0 obydwu bitmap pokrywają się. Zaczynamy od punktu 1,1 i pobieramy z mapy zniekształceń różnicę jasności pomiędzy pikselami (1,1) oraz (0,1), czyli w osi poziomej (deltaX) oraz różnicę między pikselami (1,1) oraz (1,0) czyli w osi pionowej (deltaY). Mając obliczone te dwie wartości tworzymy z nich pseudowektor, który wyznacza w jakiej odległości od aktualnie przetwarzanego punktu mamy pobrać wartość światła (z tej radialnej tekstury). Po prostu dodajemy do aktualnego x,y obliczone wartości delta i mamy współrzędnego piksela z mapy światła. Jeśli aktualnie znajdowaliśmy się w punkcie (0,0) mapy światła, czyli ciemnym, ale delta była duża w obydwu kierunkach, to znaczy, że przeskoczyliśmy z ciemnego do jasnego punktu bitmapy światła. Co by się zgadzało, bo była to ostra "krawędź" do góry. Jeśli delty wynosiły 0, to pozostaniemy w tym samym punkcie światła, i to również się zgadza, bo oznacza to, że mamy do czynienia z płaską powierzchnią. W zależności od tego jakie są wartości deltaX oraz deltaY, skaczemy po naszej mapie światła w różnych kierunkach. Jeśli tekstura będzie mieć wszystkie piksele o jednakowym kolorze, to delty w każdym punkcie będą mieć wartości 0 i wynikiem będzie po prostu tekstura identyczna z teksturą światła. Natomiast czym bardziej różnorodne są piksele, tym "pomarszczenie" tekstury światła będzie większe. Jednak usuwając założenie, że światło świeci centralnie, ta sama krawędź może być doliną jeśli światło świeci z lewej strony, ale i górką jeśli światło świeci z prawej strony. Czyli to nasze "marszczenie" będzie pobierać punkt z mapy światła w zależności z jakiego punktu wystartujemy (gdzie będzie centralny punkt światła).
Na poniższym rysunku starałem się zwizualizować tą kwestię. Punkt numer 1 jest aktualnym punktem na mapie światła (albo początkowym, albo wynikającym z poprzedniego obliczenia). Delta między punktami 1 i 2 jest mniejsza niż między punktami 2 oraz 3, bo różnice jasności są inne. Szare krędzi to oczywiście bitmapa światła.
Jeśli nasza tekstura światła będzie tylko w odcieniach szarości i wynikowy efekt również chcemy uzyskać w takiej tonacji, to uzyskany z mapy światła piksel wstawiamy jako piksel wynikowy. Jeśli natomiast chcemy uzyskać efekt kolorowy, czyli nie tracić barw źródłowej tekstury, wtedy piksel tejże tekstury mnożymy przez wartość piksela światła, czyli po prostu albo go podjaśniamy, albo ściemniamy.
Oświeć mnie kodem!
Tyle na temat teorii. Warto będzie się przyjrzeć jak wygląda skrypt odpowiadający za ten efekt. No i oczywiście efekt... efektu! W tym wpisie będzie to bumpmapping czarno-biały.
<canvas id="canvas"></canvas>
+
/* nasz element <canvas>,
do którego wczytamy
obraz źródłowy
i wygenerujemy
docelowy
*/
const canvas = document.getElementById("canvas");
/* promień tekstury
światła
*/
const r = 300;
/* mapa światła
*/
const lightMap = [];
/* mapa zniekształceń
*/
const bumpMap = [];
/* generujemy mapę światła,
czyli ten radialny gradient
*/
for (let y = 0; y < r * 2; y++) {
for (let x = 0; x < r * 2; x++) {
/* natężenie światła liczymy jako
odległość danego punktu
od środka
*/
const intensity =
(1 -
Math.sqrt((x - r) * (x - r) + (r - y) * (r - y)) /
r) *
255;
if (lightMap[x] === undefined) lightMap[x] = [];
lightMap[x][y] = parseInt(intensity);
}
}
/* wczytujemy nasz
źródłowy obrazek
*/
let sourceImage = new Image();
sourceImage.onload = function() {
const width = this.width;
const height = this.height;
/* ustawiamy <canvas>
na wymiary takie
jak źródłowy obrazek
(i wynikowy)
*/
canvas.setAttribute("width", width);
canvas.setAttribute("height", height);
const ctx = canvas.getContext("2d");
/* kreślimy obrazek
na <canvas>
*/
ctx.drawImage(sourceImage, 0, 0);
/* i teraz możemy
pobrać wartości RGBA
każdego piksela
*/
const imagePixels = ctx.getImageData(0, 0, width, height);
/* tu będziemy liczyć nasze
deltaX oraz deltaY,
czyli mapę zniekształceń
*/
for (let y = 1; y < height; y++) {
for (let x = 1; x < width; x++) {
/* potrzebnę będą
wartości RGB piksela
aktualnego, na lewo,
oraz jeden wyżej
*/
const rXY =
imagePixels.data[y * (imagePixels.width * 4) + x * 4 + 0];
const gXY =
imagePixels.data[y * (imagePixels.width * 4) + x * 4 + 1];
const bXY =
imagePixels.data[y * (imagePixels.width * 4) + x * 4 + 2];
const rX1Y =
imagePixels.data[y * (imagePixels.width * 4) + (x - 1) * 4 + 0];
const gX1Y =
imagePixels.data[y * (imagePixels.width * 4) + (x - 1) * 4 + 1];
const bX1Y =
imagePixels.data[y * (imagePixels.width * 4) + (x - 1) * 4 + 2];
const rXY1 =
imagePixels.data[(y - 1) * (imagePixels.width * 4) + x * 4 + 0];
const gXY1 =
imagePixels.data[(y - 1) * (imagePixels.width * 4) + x * 4 + 1];
const bXY1 =
imagePixels.data[(y - 1) * (imagePixels.width * 4) + x * 4 + 2];
/* mając wartości RGB
w uproszczony sposób
obliczamy piksel
czarno-biały
*/
const grayXY = parseInt((rXY + gXY + bXY) / 3);
const grayX1Y = parseInt((rX1Y + gX1Y + bX1Y) / 3);
const grayXY1 = parseInt((rXY1 + gXY1 + bXY1) / 3);
/* teraz możemy
policzyć delty
*/
const deltaX = grayXY - grayX1Y;
const deltaY = grayXY - grayXY1;
if (bumpMap[x] === undefined) bumpMap[x] = [];
if (bumpMap[x][y] === undefined) bumpMap[x][y] = [];
/* wstawiamy je
do tablicy
zniekształceń
*/
bumpMap[x][y][0] = deltaX;
bumpMap[x][y][1] = deltaY;
}
}
/* przygotowujemy tablicę
dla wynikowych pikseli,
którą wstawimy do używanego
wcześniej <canvas>, bo obraz
źródłowy nie będzie nam potrzebny
*/
const newPixels = ctx.createImageData(width, height);
const newPixelsDataWidth = newPixels.width << 2;
/* możemy poruszać
myszą po ekranie,
czyli tak naprawdę
zmieniamy położenie
bitmapy światła
względem bitmapy obrazka,
to o czym pisałem wyżej,
w zależności, po której
stronie krawędzi jest
światło, ta albo jest
górką, albo doliną
*/
document.addEventListener("mousemove", function(e) {
/* tu łączymy mapę
zniekształceń
z mapą światła
*/
for (let y = 1; y < height; y++) {
for (let x = 1; x < width; x++) {
const offset = y * newPixelsDataWidth + (x << 2);
/* pobieramy delty
*/
const deltaX = bumpMap[x][y][0];
const deltaY = bumpMap[x][y][1];
/* czy badany punkt,
tj. xy+delta+kursor,
mieści się w mapie światła?
*/
if (
lightMap[x + deltaX + (r - e.clientX)] === undefined ||
lightMap[x + deltaX + (r - e.clientX)][
y + deltaY + (r - e.clientY)
] === undefined
) {
/* nie mieści się,
więc zakładamy,
że jest poza
mapą - ciemny
*/
newPixels.data[offset] = 0;
newPixels.data[offset + 1] = 0;
newPixels.data[offset + 2] = 0;
} else {
/* mieści się, więc
pobieramy go
*/
const intensity =
lightMap[x + deltaX + (r - e.clientX)][
y + deltaY + (r - e.clientY)
];
/* jasność punktu
światła stanowi
nowy piksel
na wynikowej
bitmapie
*/
newPixels.data[offset + 0] = intensity;
newPixels.data[offset + 1] = intensity;
newPixels.data[offset + 2] = intensity;
}
/* piksel nieprzezroczysty
*/
newPixels.data[offset + 3] = 255;
}
}
/* wstawiamy nowe piksele
do <canvas>
*/
ctx.putImageData(newPixels, 0, 0);
});
};
sourceImage.src = "dollar-517113_640.jpg";
Zobacz jaki efekt końcowy bumpmappingu 2D można osiągnąć na zwykłym, płaskim obrazku.
Ze zdrową głową zapraszam Cię na kolejny wpis. Do przeczytania!
Przydatne linki:
Bumpmapping w Wikipedii.
The Fast & The Canvas, czyli szybki dostęp do pikseli na Canvas.
Bitmapa źródłowa z Pixabay.