NIKOLA UBAVIĆ

Uvod u raytracing

U ovom tekstu pokušaću da kroz jednostavan primer pokažem osnove funkcionisanja raytracing algoritma. Rejtrejsing algoritam generiše fotorealistične slike objekata u prostoru, zbog čega se koristi u programima za modelovanje i u (novijim) igricama.

Već na samom početku teksta ćemo, uz malo koda, dobiti program koji može da renderuje sliku sfere u prostoru. Zatim ćemo taj program postepeno proširivati implementirajući razne tehnike koje doprinose realističnosti slike. Ako ispratite ceo tekst, na samom kraju imaćete rejtrejser (raytracer) koji je sposoban da izrenderuje slike poput sledeće:

Finalni render. Na sivoj sceni se nalazi pet sfera pored kojih su tri izvora svetlosti.

Za praćenje ovog teksta biće vam potrebne minimalne programerske sposobnosti. Za potpuno razumevanje teksta biće vam potrebna i neka osnovna znanja iz srednjoškolske matematike i fizike. Kako znam da mnogi zaborave takve informacije nakon srednje škole, potrudio sam se da ispišem detaljnija pojašnjena za sve one stvari koje mogu biti problem. Ta objašnjenja se nalaze u sivim pravougaonicima koji se mogu otvarati ili zatvarati pritiskom na naslov.

O objašnjenjima

Objašnjena nisu neophodna za praćenje daljeg izlaganja ali mogu produbiti razumevanje materije. Za neke čitaoce će objašnjenja biti nepotrebna, te bih njima savetovao da ne troše čitalačku pažnju na njih. Za druge čitaoce će možda biti preobimna, pa bih i njima savetovao da ih privremeno preskoče, a da se vrate na njih kada se materija malo “slegne”.

Sva teorijska razmatranja u tekstu ispraćena su konkretnim kodom. Kompletan kôd rejtrejsera posle svakog implementiranog poboljšanja dat je u git istoriji repozitorijuma Zrak, a u samom tekstu su postavljeni linkovi (označeni sa 🔷) ka odgovarajućim git unosima.

Kôd koji prati ovaj tekst napisan je u Python jeziku, ali vi možete pratiti tekst kucajući kod u svom omiljenom jeziku. Pajton jezik je zbog svoje neefikasnosti verovatno poslednji jezik koji želite da koristite za pisanje rejtrejsera. Međutim, Pajton jezik je veoma čitljiv i mogu ga razumeti svi koji poznaju barem jedan imperativni jezik. Čak i ako niste nikad pisali Pajton, razumećete kodove koje ću u nastavku navesti.

Svakako vam savetujem da ovaj tekst ispratite baš tako što ćete paralelno sa čitanjem implementirati sami svoj rejtrejser. Na taj način ćete biti sigurni da zaista razumete materiju. Ipak, ne morate se slepo držati algoritama koji su izneti ovde. Dobro je ako eksperimentišete, istražujete, menjate parametre i sl. i na taj način dođete do nekih zanimljivih rezultata ili efikasnijih algoritama. Ako poslušate moj savet, uvidećete da je programiranje rejtrejsera veoma zabavno, čak i kada taj rejtrejser ne funkcioniše kako ste zamislili.

Suština algoritma

Rejtrejsing algoritam funkcioniše veoma slično kao što funkcioniše ljudsko oko ili kamera.

Zamislimo da se u prostoru nalazi jedna sfera, tačkasti izvor svetlosti i posmatrač, tj. vi. Sfera i izvor svetlosti su postavljeni tako da vidite sferu ispred sebe. Vi vidite sferu jer se zraci svetlosti odbijaju od sfere i ulaze u vaše oko gde prave sliku na mrežnjači. Ono što vi vidite može se predstaviti kao slika na jednom prozirnom platnu koje se nalazi između vas i sfere. Ta slika je dobijena tako što je svaka tačka slike “obojena” u skladu sa bojom zraka koji prolazi kroz nju i upada u oko. Stručno rečeno, slika je dobijena centralnom projekcijom prostora na platno, pri čemu je vaše oko centar te projekcije.

Centralna projekcija

Rejtrejsing algoritam simulira goreopisani proces, s jednom malom razlikom: umesto da se putanja svetlosti prati od izvora ka oku, u rejtrejsing algoritmu se putanja svetlosti prati od oka ka izvoru svetlosti. Razlog za to je sasvim jednostavan: većina svetlosnih zrakova nikada i ne završi u oku, već odluta u prostor. Zbog toga, nema ni svrhe pratiti putanje tih zrakova. Sasvim je dovoljno pratiti samo one zrakove koji su upali u oko.

Ono što nam predstoji u narednim redovima jeste konstrukcija programa koji izračunava putanje svetlosnih zrakova na osnovu pozicije izvora svetlosti, objekata i kamere (vodeći pritom računa o fizičkim fenomenima kao što su rasipanje, odbijanje i prelamanje svetlosti…).

Kreiranje slike

Na samom početku napisaćemo kôd koji pravi samu sliku (png fajl). Za to je potrebno da se opredelite za neku od biblioteka koje su dostupne za vaš jezik1. Ja sam se odlučio za pyplot biblioteku.

Koju god biblioteku da nameravate da koristite, postupak kreiranja slike je gotovo isti. Prvo se u memoriji konstruiše jedna matrica piksela, koja se zatim popuni informacijama o bojama. Na samom kraju, pozivom odgovarajuće funkcije podaci iz te matrice se zapisuju u fajl. U Pajtonu, kôd izgleda ovako:

import numpy as np
import matplotlib.pyplot

sirina = 1000
visina = 600

slika = np.zeros((visina, sirina, 3))

for i in range(visina):
    for j in range(sirina):
        slika[i, j] = np.array([0, i/visina, j/sirina])

matplotlib.pyplot.imsave('slika.png', slika)
Pokretanje Python programa

Da biste pokrenuli navedeni Pajton kod, potrebno je da imate instalirane biblioteke numpy i matplotlib. Ove biblioteke možete instalirati pokretanjem komande

$ pip install numpy matplotlib

Nakon instaliranja navedenih biblioteka program jednostavno pokrenite komandom

$ python3 ime_datoteke.py

Pokretanjem gornjeg koda dobijamo narednu sliku:

Slika prikazuje ravnomeran prelaz zelenih i plavih nijansi.
🔷 Kreiranje slike. Boja svakog piksela je određena vektorom np.array([0, i/visina, j/sirina]) - mešavina plave i zelene boje koja zavisi od pozicije piksela.

Postavka kamere

Za nastavak teksta potrebno je poznavati termine kao što su vektor, koordinate vektora i skalarni proizvod. Ako imate barem malo znanja o navedenim pojmovima, osećajte se slobodnim da preskočite naredna dva pojašnjenja.

Vektori

U matematici vektori se definišu kao elementi algebarske strukture koju nazivamo vektorski prostor. Za naše potrebe, ovako apstraktna definicija nije mnogo korisna, pa ćemo mi vektor definisati na intuitivan, geometrijski, način. Za nas će vektor biti jedna strelica (usmerena duž) u prostoru (u našem slučaju dvodimenzionalnom ili trodimenzionalnom). Pritom, dve strelice koje se paralelnim pomeranjem mogu “preklopiti”, smatraćemo za isti vektor (zato se ponekad vektori nazivaju i slobodni vektori).

Vekore ćemo u ovom tekstu označavati sa strelicom iznad imena kao na primer v ili AB. Kada se ime vektora sastoji od dva velika slova, onda ta slova označavaju početnu i krajnju tačku vektora.

Od svih vektora ističe se nula vektor 0. On predstavlja usmerenu duž koja počinje i završava se u istoj tački (bilo kojoj, npr. AA).

Svaki nenula vektor možemo okarakterisati sa tri veličine: pravac, smer i intenzitet. Pravac predstavlja pravu na kojoj vektor “leži”, smer predstavlja usmerenje vektora na toj pravi, dok intenzitet predstavlja dužinu vektora. Intenzitet vektora v označavamo sa v. Broj v nazivamo još i norma vektora.

Nula vektor nema definisan pravac i smer, a intenzitet mu je 0 (i jedini je vektor sa intenzitetom 0).

Ključna osobina vektora je ta što se oni mogu međusobno sabirati (ako pripadaju istom prostoru) i množiti skalarima tj. realnim brojevima.

Sabiranje vektora je veoma jednostavna operacija: da bismo našli zbir vektora a i b potrebno je samo da početak vektora b prenesemo na kraj vektora a. Njihov zbir, vektor a+b, biće vektor koji počinje u početku vektora a i završava se na kraju vektora b. Može se lako dokazati da prilikom sabiranja redosled vektora nije bitan, odnosno da važi a+b=b+a. Takođe, sabiranje vektora sa nula vektorom, daje isti vektor, baš kao i kod sabiranja brojeva.

Sabiranje vektora

Druga operacija koja je karakteristična za vektore je množenje s skalarima. Neformalno govoreći, proizvod skalara (broja) a i vektora v je vektor av koji je nastao izduživanjem (odnosno sabijanjem) vektora a … puta. Množenjem negativnog broja sa vektorom menjamo smer vektoru, a množenjem bilo kog vektora sa nulom dobijamo nula vektor 0.

Vektor 3v je tri puta duži od vektora v, dok su im pravac i smer isti. Vektor 0.5v je dva puta kraći od vektora v, i pravac i smer su im isti. Vektor (1)v je iste dužine i istog pravca kao vektor v, ali su im smerovi suprotni.

Sada kada smo uveli pojmove sabiranja vektora i množenja skalarom, možemo uvesti i pojam linearne kombinacije. Linearana kombinacija vektora v1,v2,,vn je svaki vektor koji se može dobiti od v1,v2,,vn sabiranjem i skalarnim množenjem. Na primer, v1+v2+v3, zatim 2v1+v3 kao i 2v1+4v2+8v3 jesu linearne kombinacije vektora v1,v2,v3.

Iako je geometrijska definicija vektora jasna, ipak nam ona na prvi pogled ne daje način da implementiramo pojam vektora na računaru. Zbog toga je potrebno uvesti pojam baze i koordinata. Baza je kolekcija vektora, takva da se svaki vektor iz prostora može izraziti na jedinstven način kao linearna kombinacija elemenata te kolekcije. Ostavićemo za neke naprednije matematičke tekstove detaljnu analizu ove definicije, a mi ćemo na primeru dvodimenzionalnog i trodimenzionalnog prostora videti šta baza predstavlja.

Nije teško uveriti se da u dvodimenzionalnom prostoru svaka kolekcija od dva nenula vektora [i,j] koji pritom nemaju isti pravac, čini jednu bazu. Svaki drugi vektor v može se izraziti kao linearna kombinacija ova dva vektora, odnosno važi v=xi+yj gde su x i y neki realni brojevi. Uređeni par (x,y) nazivamo koordinate vektora v u odnosu na bazu [i,j].

Baza u dvodimenzionalnom prostoru. Vektor a jeste linearna kombinacija 2.2i+0.6j pa su stoga njegove koordinate u u bazi (i,j) baš (2.2,0.6).

U trodimenzionalnom vektorskom prostoru, svaka kolekcija od tri nenula vektora [i,j,k] koji po parovima nemaju isti pravac niti sva tri pripadaju istoj ravni, čini bazu. U trodimenzionalnom prostoru, svaki vektor v se može predstaviti kao v=xi+yj+zk i pritom su (x,y,z) koordinate vektora v u bazi [i,j,k].

Iako možda nikad niste čuli za pojam baze, verovatno ste čuli za pojam koordinate. To je tako, jer se u geometriji gotovo uvek za bazu uzima skup vektora koji su jedinične dužine i međusobno normalni. U tom slučaju, pojam koordinata je sasvim jasan i prirodan, jer one predstavljaju udaljenosti kraja vektora od koordinatnih osi odnosno koordinatnih ravni. I mi ćemo u nastavku teksta uvek uzimati takve baze. Inače, takve baze nazivamo ortonormirane.

Koordinate su korisne jer nam omogućavaju da lako predstavljamo vektore u računaru, ali i da računamo sa tim predstavama. Na primer ako vektor v1 ima koordinate (x1,y1) a vektor v2 ima koordinate (x2,y2) onda zbir v1+v2 ima koordinate zbira (x1+x2,y1+y2). Kratko rečeno, zbir koordinata je koordinata zbira. Takođe, proizvod skalara i vektora av1 ima koordinate (ax1,ax2). Isti principi važe i kod trodimenzionalnih vektora.
Skalarni proizvod

Skalarni proizvod je operacija pri kojoj se svakom paru vektora dodeljuje jedan skalar. Skalarni proizvod vektora v i w, u oznaci vw se definiše kao vw=vwcos(v,w), gde je (v,w) ugao između vektora v i w.

Nije teško uverti se da skalarni proizvod poseduje naredne osobine:

Zbog svojstva homogenosti i distributivnosti, skalarni proizvod je moguće izračunati samo na osnovu koordinata kad god su dati skalarni proizvodi elemenata baze. Zaista, ako je v=xvi+yvj+zvk i w=xwi+ywj+zwk, tada se odmah dobija da je vw=(xvi+yvj+zvk)(xwi+ywj+zwk)=xvxwii+xvywij+xvzwik+yvxwji+yvywjj+yvzwjk+zvxwki+zvywkj+zvzwkk.

Ako je baza {i,j,k} ortonormirana (što će kod nas uvek biti slučaj) tada je ii=jj=kk=1 dok su ostali skalarni proizvodi nule, pa dobijamo dobro poznati izraz za skalarni proizvod vw=xvxw+yvyw+zvzw.(1)

Jednačina (1) nam omogućuje da računamo skalarne proizvode na računaru. Takođe, kako za svaki vektor v važi vv=vvcos(v,v)=v2, sledi da na računaru možemo računati i dužine vektora po formuli v=vv. Štaviše, sada kada znamo kako da računamo skalarne proizvode i dužine, možemo računati i kosinuse uglova između nenula vektora po formuli cos(v,w)=vwvw.

Koordinatni sistem ćemo postaviti u centar kamere (oka) tako da je pravac gledanja u pravcu negativne z poluose. Slika (platno iz prethodne sekcije) na koju ćemo projektovati scenu je pravougaonik širine 2 postavljen paralelno sa xy ravni tako da njegov centar seče z osu u tački (0,0,1). Ova postavka je prikazana na narednoj slici.

Postavka platna u prostoru. Centar koordinatnog sistema predstavlja i centar kamere

Plavi vektor na prethodnoj slici označava vektor koji povezuje centar koordinatnog početka sa gornjim levim uglom piksela na platnu2. Nije teško uvideti da su koordinate tog vektora date sa

x=1+2𝚒sirinay=1λ(1+2𝚓visina)z=1(2)

gde je (i,j) položaj piksela u matrici (indeks), a λ format slike tj. količnik širine i visine slike.

Izvođenje vektora pravca

Platno na koje projektujemo sliku je postavljeno u ravni z=1. Stoga je i z koordinata vektora pravca 1.

Dalje, primetimo da je platno podelejno na sirina kolona i visina vrsta. Svaka od ćelija te podele predstavlja jedan piksel slike. Kako je platno široko 2, sledi da je svaka od kolona, a samim tim i svaki piksel, široka tačno 2/sirina, gde je sirina broj kolona. Kako leva ivica platna ima x koordinatu 1 sledi da je i-ti piksel ima koordinatu kao što je napisano u (2).

Sličnim postpkom dobijamo i y koordinatu, s tim što izraz delimo sa λ jer je platno visoko tačno 2/λ.

Vektor dat sa (2) je vektor pravca zraka svetlosti kojeg ćemo da “pustimo” iz kamere ka prostoru (u prvoj sekciji sam napomenuo da ćemo program konstruisati tako da putanja svetlosti prati od kamere ka izvoru svetlosti). Sam zrak ćemo predstaviti kao strukturu koja sadrži vektor pravca kretanja (to će biti (2)), kao i početnu tačku zraka (to će biti koordinatni početak).

Vektor dat sa (2) nema jediničnu dužinu, a nama će kasnije biti korisno da baš bude jedinične norme. Zbog toga ćemo implementirati i funkciju normiraj koja normira dati nenula vektor3.

Kompletan program za sada izgleda ovako:

import numpy as np
import matplotlib.pyplot
import math

sirina = 1000
visina = 600


def duzina(vektor):
    return math.sqrt(np.dot(vektor, vektor))


def normiraj(vektor):
    d = duzina(vektor)

    if d == 0:
        return vektor
    else:
        return vektor/d


formatSlike = sirina/visina
slika = np.zeros((visina, sirina, 3))

for i in range(visina):
    y = (1 - 2 * i/visina) / formatSlike

    for j in range(sirina):
        x = -1 + 2 * j/sirina
        zrak = {
            'pravac': normiraj(np.array([x, y, -1])),
            'tacka': np.array([0, 0, 0])
        }

        slika[i, j] = np.array([0, 0, 0])

matplotlib.pyplot.imsave('slika.png', slika)

Prvi zraci

Jednačina prave

Svaka prava je određena sa jednim pravcem i jednom tačkom koja pripada toj pravoj. Ovu karakterizaciju možemo iskoristiti za pronalaženje jednačine koja opisuje pravu. Neka je A jedna tačka koja se nalazi na pravoj i neka je v vektor pravca te prave. Tada za svaku tačku X sa te prave važi da je AX kolinearan sa vektorom v, odnosno postoji realan broj t takav da je AX=tv. Međutim, znamo da je i AX=OXOA. Kombinujući ove dve jednakosti dobijamo OX=OA+tv(3) što predstavlja jednačinu prave. Za naše potrebe ovu jednačinu ćemo koristiti u koordinatnom obliku koji glasi (x,y,z)=(xA+txv,yA+tyv,zA+tzv) gde su (x,y,z) koordinate tačke X, (xA,yA,zA) koordinate tačke A a (xv,yv,zv) koordinate vektora v.

Jednačina sfere i presek prave i sfere Sfera je skup tačaka koje se nalaze na istom odstojanju r od neke tačke C. Prema tome neka tačka X pripada sferi ako i samo ako važi CX=r. Kako je CX=OXOC dobijamo da je (OXOC)(OXOC)=r2.(4) Ako tačka C ima koordinate (xC,yC,zC) a tačka X ima koordinate (x,y,z), tada iz gornje jednakosti i definicije skalarnog proizvoda dobijamo jednačinu sfere (xxC)2+(yyC)2+(zzC)2=r2.

Sada ćemo da implementiramo renderovanje jedne sfere. Definišimo sferu kao strukturu u kojoj polje 'pprecnik' označava poluprečnik sfere.

sfera1 = {
    'centar': np.array([0, 0, -4]),
    'pprecnik': 1
}

Da bismo odredili da li zrak seče sferu, posmatrajmo narednu sliku:

Na slici je sa C označen centar sfere, sa r poluprečnik, sa p vektor pravca zraka, sa D normalna projekcija tačke C na pravu određenu vektorom p. Očigledno, zrak seče sferu ako i samo ako je CDr, te je prema tome dovoljno samo odrediti CD. To možemo lako uraditi koristeći Pitagorinu teoremu primenjenu na trougao ODC sa pravim uglom kod temena D. Kako je p jedinične dužine, važi OD=cos(OC,p)OC=OCp, pa je CD2=OCOC(OCp)2.

U Pajtonu je jednostavno implementirati navedeni kriterijum:

def presekZrakaISfere(zrak, sfera):
    ss = np.dot(sfera['centar'], sfera['centar'])
    sz = np.dot(sfera['centar'], zrak['pravac'])

    if sz > 0 and ss - sz * sz < sfera['pprecnik']:
        return 1

    return 0
Uslov sz > 0 je postavljen da bismo renderovali samo sfere koje se nalaze “ispred” kamere.

U dvostrukoj petlji iskoristićemo funkciju presekZrakaISfere da bismo za svaki zrak proverili da li seče sferu. Ako zrak seče sferu, odgovarajući piksel ćemo obojiti u belo (RGB = [1, 1, 1]), dok ćemo u suprotnom odgovarajući piksel obojiti u crno.

for i in range(visina):
    y = (1 - 2 * i/visina) / formatSlike

    for j in range(sirina):
        x = -1 + 2 * j/sirina
        zrak = {
            'pravac': normiraj(np.array([x, y, -1])),
            'tacka': np.array([0, 0, 0])
        }

        if presekZrakaISfere(zrak, sfera1):
            slika[i, j] = np.array([1, 1, 1])
        else:
            slika[i, j] = np.array([0, 0, 0])

Pokretanjem programa dobijamo prvu sliku generisanu rejtrejsingom:

Crna pozadina na kojoj se nalazi beli krug.
🔷 Implementiran raytracer

Scena sa jednim objektom nije mnogo interesantna, te ćemo stoga implementirati renderovanje niza sfera. Sve sfere ćemo predstaviti kao niz sfere čiji elementi sadrže i polje 'boja':

sfere = [
    {'centar': np.array([0, 0, -4]), 'pprecnik': 1, 'boja': np.array([1, 0, 0])},
    {'centar': np.array([-1, 0, -3]), 'pprecnik': 0.5, 'boja': np.array([0, 1, 0])},
    {'centar': np.array([0.8, 1, -4]), 'pprecnik': 0.3, 'boja': np.array([0, 0, 1])}
]

Jasno je da sada moramo ponoviti prethodni postupak za svaku sferu pojedinačno. Međutim, više nije dovoljno samo odrediti da li zrak seče neku sferu ili ne, već je potrebno odrediti i udaljenost tog preseka. Naime, kako neki zrak može seći više sfera istovremeno, moramo pažljivo odrediti koju sferu je zrak prvu presekao.

Kako nam funkcija presekZrakaISfere za sada ne daje informaciju o udaljenosti presečne tačke, izmenićemo je tako da funkcija sada vraća i samu presečnu tačku zraka i sfere (ako takva tačka postoji).

Presečne tačke sfere određene jednačinom (xxC)2+(yyC)2+(zzC)2=r2. i prave određene jednačinom (x,y,z)=(xA+txv,yA+tyv,zA+tzv) dobijamo tako što nepoznate x, y i z zamenimo u jednačinu sfere koristeći jednakosti date jednačinom prave. Na taj način dobijamo (OA+tvOC)(OA+tvOC)=r2. gde je OA=(xA,yA,zA), OC=(xC,yC,zC), i v=(xv,yv,zv). Koristeći osobine skalarnog proizvoda, dobijamo kvadratnu jednačinu po t:

vvt2+v(OAOC)t+(OAOC)(OAOC)r2=0

Iako gornja jednačina sadrži vektorske veličine u sebi, ona je zapravo skalarna jednačina u kojoj su pritom poznati svi koeficijenti uz nepoznatu t. Pri rešavanju ove jednačine izdvajaju se tri slučaja:

Tri slučaja preseka prave i sfere.

Navedenu računicu je lako implementirati u Pajtonu, što je i prikazano u narednom kodu. Potrebno je samo povesti računa o sledećem: u slučaju kada postoje dva različita preseka prave i sfere, biramo onaj presek koji je bliži kameri, a to je upravo presek koji odgovara manjem rešenju kvadratne jednačine.

def resenjaKvadratneJednacine(a, b, c):
    D = b**2 - 4 * a * c

    if D < 0:
        return (None, None)
    else:
        return ((-b - math.sqrt(D))/(2 * a), (-b + math.sqrt(D))/(2 * a))


def presekZrakaISfere(zrak, sfera):
    sz = np.dot(sfera['centar'] - zrak['tacka'], zrak['pravac'])

    if sz > 0:
        a = np.dot(zrak['pravac'], zrak['pravac'])
        b = 2 * np.dot(zrak['pravac'], zrak['tacka'] - sfera['centar'])
        c = np.dot(zrak['tacka'] - sfera['centar'], zrak['tacka'] - sfera['centar']) - sfera['pprecnik']**2
        t1, _ = resenjaKvadratneJednacine(a, b, c)

        if t1 is None:
            return (0, None)

        return (1, zrak['tacka'] + t1 * zrak['pravac'])

    return (0, None)

Sada funkciju presekZrakaISfere možemo iskoristiti za pronalaženje sfere koju zrak prvu seče. Zrak ćemo obojiti bojom te sfere.

udaljenostPrvogPreseka = math.inf
boja = np.array([0, 0, 0])

for sfera in sfere:
    zrakSeceSferu, tackaPreseka = presekZrakaISfere(zrak, sfera)

    if not zrakSeceSferu:
        continue

    duzinaZraka = duzina(tackaPreseka - zrak['tacka'])

    if duzinaZraka < udaljenostPrvogPreseka:
        udaljenostPrvogPreseka = duzinaZraka
        boja = sfera['boja']

slika[i, j] = boja

Pokretanjem programa dobijamo:

Crna pozadina na kojoj se nalaze crveni, zeleni i plavi krug, različitih veličina. Krugovi se delimično preklapaju.
🔷 Pravilno iscrtavanje niza sfera

Senčenje

Rezultat našeg programa sada deluje malo zanimljivije, ali i dalje ne odaje utisak trodimenzionalne scene. Zato ćemo implementirati senčenje sfera, tehniku koja će odmah dati trodimenzionalni izgled generisanim slikama.

Tačkasti izvor svetlosti možemo predstaviti jednostavnim objektom:

izvorSvetlosti = {
    'centar': np.array([ -2, 5, 0]),
    'boja': np.array([30, 30, 30])
}

Primetimo da u ovom slučaju vektor boje ima koordinate koje su mnogo veće od 1. Kako intenzitet svetlosti opada s kvadratom rastojanja od izvora svetlosti, neophodno je definisati “malo veći” vektor boje da bi se scena dovoljno osvetlila.

Za senčenje sfera koristićemo Lambertov4 zakon koji veoma dobro opisuje odbijanje svetlosti od mat materijala. Po Lambertovom zakonu zrak svetlosti se odbija od mat materijala5 podjednako u svim pravcima, intezitet Io odbijenog zraka je proporcionalan veličini Iucosθ gde je Iu intenzitet upadnog zraka, a θ ugao između normale površi i upadnog zraka.

Za izvođenje Lambertovog zakona potrebno je poznavati principe radiometrije, što je svakako van domašaja ovog teksta. Ipak, naredna slika bi možda mogla da vam dâ intuitivnu ideju zašto zakon važi.

Na slici je prikazan zrak konstantnog intenziteta koji pada na površ i istaknuti su poprečni preseci, pravugaonici, T i A kroz koje svetlost prolazi odnosno pada. Ugao α između pravca upadnog zraka i normale površi jednak je uglu između pravugaonika T i A i pritom za njihove površine važi PT=cosαPA. Ako prihvatimo činjenicu da je osvetljenost neke površi obrnuto proporcionalna površini koju zrak obasjava, onda bi trebalo da intuitivno shvatimo zašto Lambertov zakon važi…

Senčenje pomoću Lambertovog zakona je jednostavno implementirati. Prvo izračunamo intenzitet svetlosti koja pada na površ. Taj intenzitet je, naravno, boja iz strukture izvorSvetlosti umanjena za kvadrat rastojanja između izvora svetlosti i tačke koju posmatramo. Zatim, na osnovu tog intenziteta izračunamo koeficijent osvetljenje po Lambertovom zakonu. Koeficijent osvetljene množimo dalje vektorom boje sfere.

normalaSfere = normiraj(tackaPrvogPreseka - najblizaSfera['centar'])
pravacKaSvetlu = normiraj(izvorSvetlosti['centar'] - tackaPrvogPreseka)
kosinus = kosinusUgla(pravacKaSvetlu, normalaSfere)
rastojanje = duzina(izvorSvetlosti['centar'] - tackaPrvogPreseka)
osvetljenje = kosinus * izvorSvetlosti['boja'] / rastojanje**2
boja = najblizaSfera['boja'] * osvetljenje

Pri opisanom računu dobijamo vektor boja kojim je potrebno obojiti piksel. Međutim, prilikom kreiranja slike, vektor boje mora imati koordinate koje leže u intervalu [0,1]. Da bismo osigurali da ovo važi, iskoristićemo funkciju clip iz Numpy biblioteke. Stoga, umesto

slika[i, j] = boja

stavljamo

slika[i, j] = np.clip(boja, 0, 1)

Pokretanjem programa dobijamo scenu koja podseća na one iz filma 2001: Odiseja u svemiru

Crveni, zeleni i plavi krug su sada osenčeni i odaju utisak sfera u prostoru.
🔷 Senčenje sfera

Bilo bi lepo da u našu scenu unesemo jednu ravan, koja će delovati kao podloga za ostale sfere. Da bismo to uradili možemo postupiti na dva načina:

Ja se odlučujem za drugi način jer je dosta kraći i stoga pogodniji za ovaj tekst. Ipak, ako želite da napravite nešto više nego što ovaj tekst nudi, onda svakako vredi da krenete prvim putem. Ako dobro dizajnirate program, moći ćete da lako dodajete nove geometrijske objekte u svoj rejtrejser. Nakon ravni, možete implementirati trouglove, a nakon toga praktično imate sve što vam je potrebno za uvoz 3D modela u program8.

Elem, dodavanjem ogromne sive sfere dobijamo narednu sliku:

Ispod crvene, zelene i plave sfere se nalazi siva podloga koja se pruža u nedogled.
🔷 Dodata velika sfera

Na prethodnoj slici uočava se jedan veliki problem: sfere ne bacaju senku.

Ovaj problem rešavamo na najjednostavniji mogući način: kada odredimo tačku koju želimo da renderujemo (to je ona koja je najbliža kameri od svih preseka datog zraka i sfera), tada ćemo prvo proveriti da li između te tačke i izvora svetlosti postoji neka druga sfera. Ako takva sfera postoji, onda tu tačku bojimo u crno, a u suprotnom je bojimo u skladu s Lambertovim zakonom.

zrakSenke = {
    'pravac': pravacKaSvetlu,
    'tacka': tackaPrvogPreseka
}

for sfera in sfere:
    zrakSenkeSeceSferu, presekZrakaSenke = presekZrakaISfere(zrakSenke, sfera)

    if zrakSenkeSeceSferu and
    duzina(presekZrakaSenke - tackaPrvogPreseka) < duzina(izvorSvetlosti['centar'] - tackaPrvogPreseka):
        uSenci = 1
        break
    else:
        uSenci = 0
Određeni delovi sfera i podloge su u senci.
🔷 Implementirane senke

Višestruko osvetljenje

Scena bi bila mnogo zanimljivija ako bi bila istovremeno osvetljena sa više izvora svetlosti. Upravo ćemo to sada i implementirati.

Kao i u slučaju sfera, izvor svetlosti ćemo zameniti nizom izvora svetlosti

izvoriSvetlosti = [
    {'centar': np.array([-2, 5, 0]), 'boja': np.array([20, 20, 20])},
    {'centar': np.array([3, 0, -6]), 'boja': np.array([ 0, 0, 1])},
    {'centar': np.array([2.7, 0, -4]), 'boja': np.array([ 1, 0, 0])},
    {'centar': np.array([4, 0, -5]), 'boja': np.array([ 0, 1, 0])}
]

Sada je dovoljno da kôd koji je zadužen za senčenje sfere, postavimo u petlju koja prolazi kroz sve izvore svetlosti. Pritom, krajnju boju tačke sfere formiramo kao zbir svih boja koje se dobijaju osvetljivanjem pojedinačnih izvora svetlosti:

if tackaPrvogPreseka is not None:

    for izvorSvetlosti in izvoriSvetlosti:
        # ...
        # Kod koji smo do sada imali za izračunavanje
        # osvetljenja na osnovu Lamertovog senčenja
        # ...

        if not uSenci:
            boja = boja + najblizaSfera['boja'] * osvetljenje

Pokretanjem koda dobijamo narednu sliku:

Desno od sfera, podloga je osvetljena sa tri različita izvora svetlosti: crvenim, zelenim i plavim. Osvetljenja su slaba, i raspoređena u horizontalni trougao.
🔷 Višestruko osvetljenje

Odmah se uočava da bi bilo dobro kada bi i sami izvori svetlosti bili vidljivi. Ovo ćemo kasnije implementirati.

Reflektivni materijali

Lambertovo senčenje je sasvim dobro za prikazivanje mat površina9. Ipak, znamo da u prirodi nisu sve površine mat već ima i onih sjajnih. Za modelovanje sjajnih površi u računarskoj grafici često se koristi Blin–Fongov model. Ovaj model je neznatno komplikovaniji od Lambertovog modela, te ga ja neću ovde prikazati. Umesto toga, implementiraću savršene reflektujuće površine, tj. ogledala.

Sve što je potrebno za modelovanje ogledala jeste zakon odbijanja svetlosti, koji kaže da se svetlost od glatke reflektujuće površi odbija tako da je upadni ugao jednak odbojnom uglu, kao i da se upadni zrak, normala na površ ogledala i odbijeni zrak nalaze u istoj ravni.

Neka je O tačka u kojoj zrak svetlosti pada na ogledalo. Neka su tačke A i B takve da je OA jedinični vektor pravca upadnog zraka a OB jedinični vektor pravca odbijenog zraka, i neka je n jedinična normala na površ ogledala. Neka je T tačka takva da je AT=OB. Kako je ugao θ1 između upadnog ugla i normale jednak uglu θ2 između dobojnog ugla i normale, normala je kolinearna sa dijagonalom deltoida AOBT. Neka je C tačka preseka dijagobala četvorougla AOBT. Kako je n=1, sledi da je OAn=OAcos(θ1)=OC. Kako je četvorougao AOBT i paralelogram, sledi da se dijagonale polove, pa je OT=2OC. Sada možemo izraziti OB kao AO+OT=AO+2OC=AO2(OAn)n
Izvođenje zakona odbijanja svetlosti

Zakon odbijanja svetlosti možemo izvesti iz Fermaovog10 principa najmanjeg vremena koji glasi: putanja svetlosti izmеđu dve tačke je takva da je vreme puta, tokom kog je pređena putanja, najkraće. Uz malo analitičke geometrije i diferencijalnog računa iz navedenog principa direktno sledi zakon odbijanja svetlosti (jedno takvo izvođenje će biti kasnije prikazano).

Ogledala formiraju virtuelnu sliku na svojoj površini, tj. u ogledalu vidimo tačno onu sliku koju bi video neko ko se nalazi naspram nas, simetrično u odnosu na ogledalo. Dakle, za implementaciju ogledala u rejtrejseru potrebno je formirati sliku unutar naše slike. Ovo je mnogo jednostavnije nego što zvuči: dovoljno je samo izdvojiti logiku koja je određivala boju zraka (tj. boju kojom bojimo odgovarajući piksel) u zasebnu funkciju, a zatim tu funkciju pozvati rekurzivno kad god je u pitanju reflektujuća površina. Da bismo osigurali da se rekurzija završava, koristićemo promenljivu dubina koja označava koliko još rekurzivnih koraka možemo da napravimo.

def bojaZraka(zrak, dubina):
    udaljenostPrvogPreseka = math.inf
    tackaPrvogPreseka = None
    najblizaSfera = None
    boja = np.array([0, 0, 0])
        
    if dubina == 0:
        return boja

    # Kôd koji određuje presek s najbližom sferom

        if tackaPrvogPreseka is not None:
        
            # Kôd koji određuje Lambertovo senčenje i smešta boju tačke u promeljivu 'boja'
            # ...

            if 'reflektivnost' in najblizaSfera:
                reflektovanZrak = {
                    'pravac': zrak['pravac'] - 2 * np.dot(zrak['pravac'], normalaSfere) * normalaSfere,
                    'tacka': tackaPrvogPreseka
                }
                r =  najblizaSfera['reflektivnost']
                boja = (1 - r) * boja + r * bojaZraka(reflektovanZrak, dubina - 1)

    return boja

Sve što je prikazano u gornjem kodu već smo imali. Jedini dodatak je poslednji blok, koji se poziva kad god sfera ima polje 'reflektivnost' u sebi. U tom slučaju, boja tačke se određuje tako što se po zakonu odbijanja svetlosti odgovarajući zrak plasira u prostor, a zatim se njegova boja pomeša s bojom sfere u zavisnosti od samog parametra 'reflektivnost'. Na primer, ako bi promenljiva 'reflektivnost' imala vrednost 1, tada bi boja upadnog zraka bila tačno jednaka boji odbojnog zraka, tj. imali bismo savršeno ogledalo. U slučaju da promenljiva 'reflektivnost' ima vrednost 0, tada bi se boja odbijenog zraka u potpunosti ignorisala, i imali bismo obično Lambertovo senčenje.

Naravno, sada je petlja po svim pikselima mnogo jednostavnija:

for i in range(visina):
    y = format * (1 - 2 * i/visina)

    for j in range(sirina):
        x = -1 + 2 * j/sirina
        zrak = {
            'pravac': normiraj(np.array([x, y, -1])),
            'tacka': np.array([0, 0, 0])
        }
        slika[i, j] = np.clip(bojaZraka(zrak, 8), 0, 1)
Na scenu je dodata siva sfera koja savršeno reflektuje svetlo. Takođe, crvena sfera je sada sjajna, ali i dalje crvena.
🔷 Reflektujući materijali: Na scenu smo dodali jednu sferu koja je totalno reflektujuća, a i crvenoj sferi smo dodali 20% reflektivnosti. Osim toga, dodat je još jedan (beo) izvor svetlosti.

Izdvajanje funkcije bojaZraka omogućava nam još neka interesantna poboljšanja. Prvo ćemo izmeniti rejtrejser tako da i izvori svetlosti postanu vidljivi. Izvore svetlosti ćemo prikazati kao sfere, ali za razliku od drugih sfera, izvori svetlosti će imati “sjaj”. To će doprineti realističnosti krajnje slike.

Dodajmo pprecnik polje za svaki izvor svetlosti, kao i polje emisija koje će označavati da se radi o izvoru svetlosti.

izvoriSvetlosti = [
    {
        'centar': np.array([-2, 5, 0]),
        'boja': np.array([20, 20, 20]),
        'emisija': True,
        'pprecnik': 1
    },
    # ...
]

Sada ćemo dodati petlju koja će određivati najbliži presek zraka s nekim izvorom svetlosti (ovakvu već petlju imamo za ostale sfere):

for izvorSvetlosti in izvoriSvetlosti:
    zrakSeceSferu, tackaPreseka = presekZrakaISfere(zrak, izvorSvetlosti)
        
    if not zrakSeceSferu:
        continue

    duzinaZraka = duzina(tackaPreseka - zrak['tacka'])

    if duzinaZraka < udaljenostPrvogPreseka:
        udaljenostPrvogPreseka = duzinaZraka
        tackaPrvogPreseka = tackaPreseka
        najblizaSfera = izvorSvetlosti

Dakle, nakon završetka ove petlje, u promenljivoj najblizaSfera imamo ili emisionu (svetleću) ili neemisionu sferu. Već smo implementirali logiku za neemisione sfere, pa nam stoga ostaje da implementiramo logiku koja određuje boju zraka kad taj zrak pogodi emisionu sferu. Emisione sfere svakako ne bi trebalo da budu osenčene niti reflektujuće. Takođe, rekli smo da želimo da vidimo sjaj oko izvora svetlosti.

U sofisticiranim rejtrejsing programima, sjaj oko izvora svetlosti se dobija implementacijom fenomena rasejanja svetlosti11. Mi ćemo se za potrebe ovog teksta poslužiti (jeftinim) trikom12.

Ideja trika je jednostavna: što je ugao pod kojim zrak pada na sferu manji, to zrak pada bliže “obodu” sfere (gledano iz pravca tačke iz koje je zrak pušten). Da bismo videli sjaj oko sfere dovoljno je da propustimo zrak kroz sferu, a zatim “pomešamo” boju tog zraka s bojom te sfere (tj. s bojom tog izvora svetlosti).

Sa slike vidimo kako konstruišemo propušteni zrak. Pravac propuštenog zraka će biti isti kao pravac originalnog zraka, a za tačku ćemo uzeti dalju presečnu tačku i sfere.
if 'emisija' in najblizaSfera:
    k = np.clip(1.5 * pow(abs(kosinusUgla(zrak['pravac'], normalaSfere)), 5), 0, 1)
    propustenZrak = {
        'pravac': zrak['pravac'],
        'tacka': tackaPrvogPreseka - 2 * np.dot(zrak['pravac'], normalaSfere) * najblizaSfera['pprecnik'] * zrak['pravac']
    }
    return k * najblizaSfera['boja'] + (1 - k) * bojaZraka(propustenZrak, dubina - 1)
Parametar k koji određuje odnos mešanja boja, konstruisan je tako da je centar sfere neproziran, dok sjaj oko sfere opada sa petim stepenom kosinusa upadnog ugla. Ovaj izbor je bio umetnička sloboda, vi svakako eksperimentišite sa različitim parametrima.
Izvori svetlosti su vidljivi.
🔷 Vidljivi izvori svetlosti
Meke senke

Sada kada su izvori svetlosti vidljivi, uočljiviji je jedan problem sa senkama. Naime, obodi senke nikad nisu oštri prelazi iz osvetljenog u neosvetljeni deo, već postepeni prelazi. Razlog tome je što ni izvori svetlosti nisu idealne tačke, već zauzimaju neki prostor, te stoga mogu delimično osvetljavati površine (u zavisnosti od procenta izvora svetlosti koji je vidljiv iz te tačke).

Osenčenost sa tačkastim izvorom svetlosti i osenčenost sa izvorom svetlosti koji nije tačkast.
Razlika između tačkastog izvora svetlosti i izvora svetlosti koji nije tačkast.

U rajtrejsingu ovaj problem možemo prevazići na dva načina:

Jasno je da je drugi način značajno vremenski zahtevniji. Ipak taj način nam automatski rešava i druge suptilnije probleme (kao što je npr. indirektno osvetljenje).

Prozirni materijali

Sem reflektivnosti, postoji još jedan svetlosni fenomen koji je jednostavan za implementaciju: refrakcija ili prelamanje svetlosti.

Prelamanje svetlosti se dešava kada svetlost prelazi iz jedne u drugu sredinu sa različitim optičkim indeksom13. Prelamanje svetlosnog zraka je opisano Snelovim14 zakonom po kom za upadni ugao θ1 i ugao prelamanja θ2 važi jednakost

v1v2=sinθ1sinθ2(5)

gde su v1 i v2 respektivno brzine prostiranja svetlosti u prvoj i drugoj sredini.

Dokaz Snelovog zakona

Snelov zakon se takođe može izvesti iz Fermaovog principa najmanjeg vremena, baš kao i zakon odbijanja svetlosti.

Posmatrajmo jedan zrak svetlosti koji prolazi kroz tačke A i B koje se nalaze u različitim optičkim sredinama. Neka su A i B normalne projekcije tačaka A i B na zajedničku granicu ovih sredina, i neka se svetlost kreće brzinom v1 u sredini u kojoj se nalazi tačka A, a brzinom v2 u sredini u kojoj se nalazi tačka B. Ovaj zrak svetlosti u nekoj tački O mora preseći granicu sredina. U ovakvoj postavci problema, Snelov zakon je ekvivalentan određivanju pozicije tačke O u odnosu na tačke A i B.

Ukupno vreme koje je potrebno svetlosti da stigne od tačke A do tačke B je T=tAO+tOB=AOv1+OBv2=AA2+AO2v1+BB2+BO2v2.(6) U navedenom izrazu sve veličine su konstantne, osim dužina AO i BO. Međutim, BO=BA-AO, iz čega sledi da (6) zavisi samo od dužine AO. Standardnim postupkom traženja ekstremuma funkcije, izvod izraza (6) po veličini AO, izjednačićemo sa nulom, čime dobijamo AOv1AA2+AO2BAAOv2BB2+(BAAO)2=0, što je upravo formula (5).

Mi ćemo na osnovu Snelovog zakona računati ugao prelamanja θ2. Za neke vrednosti veličina v1, v2 i θ1 dobija se da je sinθ2=v2sinθ1v1>1 što je nemoguće. U ovom slučaju, zrak se ne prelama u drugu sredinu već se odbija od površi po zakonu koji je već spomenut u tekstu.

Prvo ćemo u listu sfera dodati sferu koja ima polje 'transparentnost' kao i 'indeks':

sfere = [
    # ....
    {
        'centar': np.array([-1.05, 0, -2]),
        'pprecnik': 0.3,
        'boja': np.array([1, 1, 1]),
        'reflektivnost': 1,
        'transparentnost': 1,
        'indeks': 1.3
    },
]

Za sfere koje imaju polje 'transparentnost' primenićemo sledeći postupak: kada zrak svetlosti pogodi sferu, vektor pravca zraka ćemo izmeniti u skladu sa Snelovim zakonom a zatim pustiti novi zrak. Taj zrak će proći kroz istu sferu da bi se ponovo prelomio pri izlasku iz nje.

Staklena sfera kroz koju se prelama zrak. Zrak se prelama prilikom ulaska i prilikom izlaska iz sfere.

Ipak, sa opisanom idejom postoji jedan problem: funkcija presekZrakaISfere konstruiše tačku preseka na osnovu manjeg od rešenja kvadratne jednačine. To može predstavljati problem kada se zrak već nalazi u sferi, jer je tada taj presek upravo ona tačka sfere u kojoj se zrak prelomio. Zato ćemo dodati uslov koji obebeđuje da se uvek konstruiše presek zraka i sfere koje se nalazi “ispred” tačke iz koje plasiramo zrak:

def presekZrakaISfere(zrak, sfera):
    # ...
        
    t1, t2 = resenjaKvadratneJednacine(a, b, c)

    if t1 < 0:
        t1 = t2
    
    return (1, zrak['tacka'] + t1 * zrak['pravac'])

Implementacija prelemanja svetlosti je neznatno komplikovanija od implementacije ogledala: potrebno je samo povesti računa da li zrak ulazi ili izlazi iz transprentnog materijala, kao i da li je došlo totalne refleksije.

if 'transparentnost' in najblizaSfera:
    if kosinusUgla(normalaSfere, zrak['pravac']) > 0:
        normalaSfere = - normalaSfere
        indeks = najblizaSfera['indeks']
    else:
        indeks = 1 / najblizaSfera['indeks']

        kosinus = min([np.dot(normalaSfere, -1 * zrak['pravac']), 1])
        sinus = math.sqrt(1 - kosinus**2)
        if sinus <= 1:
            vNormalan = indeks * (zrak['pravac'] + kosinus * normalaSfere)
            vTransvezalan = -1 * math.sqrt(abs(1 - np.dot(vNormalan, vNormalan))) * normalaSfere
            propustenZrak = {
                'pravac': vNormalan + vTransvezalan,
                'tacka': tackaPrvogPreseka - 0.0001 * najblizaSfera['pprecnik'] * normalaSfere
            }
            t = najblizaSfera['transparentnost']
            boja = (1 - t) * boja + t * bojaZraka(propustenZrak, dubina - 1)
        else:
            reflektovanZrak = {
                'pravac': zrak['pravac'] - 2 * np.dot(zrak['pravac'], normalaSfere) * normalaSfere,
                'tacka': tackaPrvogPreseka
            }
            boja = bojaZraka(reflektovanZrak, dubina - 1)

Pokretanjem programa dobijamo veoma zanimljivu sliku:

Na scenu, levo od zelene sfere, je dodata providna sfera. Kroz nju se svetlost prelama, tako da se u njoj vide obrisi zelene sfere i podloge.
🔷 Transparentni materijali

Pozicioniranje kamere

Do sada je kamera uvek bila pozicionirana u koordinatnom početku, i bila usmerena ka negativnoj z osi. Veoma je lako implementirati logiku koja dozvoljava prozvoljno postavljanje kamere u prostoru ako znamo da koristimo vektorski proizvod.

Vektorski proizvod

Vektorski proizvod × je operacija koja svakom paru vektora a i b pridružuje vektor c tako da važi:

Vekotorski proizvod vektora a i b

Za razliku od skalarnog proizvoda, vektorski proizvod nije ni komutativan ni ascocijativan, ali poseduje analogne osobine:

Na osnovu iznetih osobina, lako se može proveriti da je skalarni proizvod vektora a=xai+yaj+zak i b=xbi+ybj+zbk dat sa

a×b=(yazbzayb)i(xazbzaxb)j+(xaybyabb)k,

što se skraćeno često zapisuje u obliku determinante

|ijkxayazaxbybzb|.

Kameru ćemo pozicionirati tako što ćemo zadati tri parametra:

Na osnovu zadatih tačaka gledajOd i gledajKa, konstruišemo jedinični vektor pravacKamere. Množeći vektor pravacKamere vektorom gore = [0, 1, 0], dobijamo vektor i_. Množeći vektorski vektore i_ i pravacKamere, dobijamo vektor j_. Po konstrukciji, vektori i_, j_ i pravacKamere su jedinični i međusobno ortogonalni.

Pozicioniranje kamere.

Sada bismo mogli da konstruišemo zrak koji puštamo iz kamere kao

zrak = {
    'pravac': normiraj(pravacKamere + x * i_ + y * j_),
    'tacka': gledajOd
}

Da bismo omogućili takozvani camera roll, tj. rotaciju kamere oko vektora pravacKamere, dovoljno je da zarotiramo vektore i_ i j_ oko vektora pravacKamere, što je i prikazano na narednoj slici:

Rotacija vektora u ravni.

Posmatrajući prethodnu sliku, gde je sa α označen ugao nagibKamere, dolazimo do koda

i2 =  math.cos(nagibKamere) * i_ + math.sin(nagibKamere) * j_
j2 = -math.sin(nagibKamere) * i_ + math.cos(nagibKamere) * j_

Kôd koji smo do sada napisali omogućuje potpunu kontolu položaja i usmerenja kamere. Ipak, jednom malom izmenom koda možemo dobiti i kontrolu faktora uvećanja kamere tj. zoom. Ako vektor pravacKamere skaliramo, tada ćemo u suštini menjati udaljenost ravni na koju projektujemo sliku od centra kamere. Takva promena će se odraziti na sliku tako što će slika biti umanjena ili uvećana.

Što je vektor pravacKamere duži, to će objekti na slici biti veći. U suštini, skaliranje vektora pravacKamere je menjanje žižine daljine.
Položaj iz kog se gledaju sfere je promenjen
🔷 Pomeranje, rotacija i uvećanje kamere

Gama korekcija

Gama korekcija je tehnika koja se može implementirati izmenom jedne jedine linije. Vi možete uzeti tu izmenu zdravo-za-gotovo, ali bih za kraj teksta želeo da napišem par reči o tome zašto je potrebno izvršiti takvu korekciju. Ako vas to ne interesuje, osećajte se slobodno da pređete na narednu sekciju.

Ljudski vid, kao i ostala čula, ne reaguje linearno na nadražaje15. Umesto toga, percepcija nadražaja je zavisna od logaritma intenziteta nadražaja. Ova činjenica je poznata kao Fehnerov16 zakon i odnosi se na gotovo sva ljudska čula.

Sa leve strane je prikazana linearna zavisnost percepcije nadražaja od inteziteta nadražaja. Crveni i plavi opseg, koji su iste širine, preslikavaju se u opsege koji su takođe iste širine. Sa desne je prikazana logaritamska zavisnost percepcije nadražaja od inteziteta nadražaja. Crveni i plavi opseg, koji su iste širine, preslikavaju se u opsege koji su različite širine. Ovo ilistruje činjenicu da je oko manje osetljivo na male promene jačine svetlosti kada je ta svetlost visokog intenziteta.

Posledica ovog fenomena jeste to da je vid osetljiviji na promene nadražaja niskog inteziteta. Konkretnije rečeno, oko bolje razlikuje tamne nijanse od svetlijih nijansi.

Gornji red pokazuje progresiju svetlijih nijansi koja deluje ravnomerna za čoveka. Donji red prikazuje fizički ravnomernu progresiju . Kao što vidimo, ljudski vid je mnogo osetljiviji na tamne nijanse dok kod svetlijih nijansi dolazi do zasićenja čula i nemogućnosti razlikavanja nadražaja.

Kada govorimo o digitalnim slikama, važno je znati da postoji samo ograničen broj nijansi kojim je opisan svaki od piksela. Tačan broj zavisi od formata u kom čuvamo sliku, a svakako je iz mnogih razloga pogodno koristiti što manji broj nijansi. Zato se prilikom konverzije analognog signala (svetla) u digitalni signal koristi gama kompresija kojom se obezbeđuje da se nijanse kodiraju neravnomerno, dajući prednost tamnijim nijansama.

Dakle, u .jpg, .png i sličnim fajlovima nalaze se vrednosti koje su dobijene gama kompresijom. Kada se navedeni fajlovi reprodukuju, tada se vrši gama dekompresija (ili gama ekspanzija), kojom se poništava efekat gama kompresije. Proces gama dekopresije vrše zajedničkim snagama korisnički programi, operativni sistem, grafička karta i monitor.

Funkcija gama kopresije i funkcija gama dekompresije bi trebalo da su međusobno inverzne funkcije.

Za nas nije važno kako se tačno vrši gama dekompresija, ali je važno da znamo da se prilikom reprodukcije slike očekuje da fajl sadrži gama kompresovane vrednosti! Zbog toga, mi moramo izvršiti gama kompresiju pre nego što sačuvamo vrednosti fajl. Srećom, to se može veoma lako uraditi primenom neke konkavne (i bijektivne) funkcije na svaku od komponenata boje. Najjednostavniji izbor je f(x)=xγ gde je γ neki broj iz intervala [0,1]. U Pajtonu za stepenovanje možemo iskoristiti pow funkciju koja, primenjena na vektor, stepenuje svaku od komponenti posebno:

slika[i, j] = np.clip(pow(bojaZraka(zrak, 8), 0.7), 0, 1)
U ovom slučaju sam uzeo γ=0.7, što je ipak možda preslabo. Najčeće se uzima γ=0.5. Takođe, često je korisno izvršiti i prosvetljenje/potamljenje slike prostim množenjem vektora boje s pozitivnim skalarom.
Na slici su prikazana, jedan pored drugog, dva rendera: sa i bez gama korekcije
🔷 Gama korekcija: Razlika između slike bez (levo) i slike sa (desno) gama korekcijom je suptilna (na nekim drugim primerima razlika je izraženija). Ipak, gama korekcija doprinosi realističnosti rendera, zbog čega je uvek treba implementirati.

Kraj

Naše putovanje se ovde završava. Nadam se da Vam se ovaj tekst svideo, i nadam se da ste ga ispratili programirajući vaš rejtrejser. Svakao, postoji još mnogo prostora za poboljšanje programa koji je konstruisan u ovom tekstu. Ako imate želju da nastavite sa kontrukcijom rejtrejsera preporučio bih vam da pokušate neki od slećih izazova:

Osećajte se slobodno da mi pošaljete zanimljive slike koje izrenderujete :)

Preporuke za dalje čitanje

Ovaj tekst predstavlja samo delić oblasti koja se naziva računarska grafika. Ovde ću navesti neke besplatne resurse koji vam mogu poslužiti u daljem istraživanju.

Za više informacija o rejtrejsingu preporučio bih:

Za više o matematici koja se koristi u računarskoj grafici preporučio bih:

Rejtrejsing je samo jedna od tehnika koja se koristi u računarskoj grafici. U 3D igricama se do skoro isključivo koristila tehnika rasterizacije. Nešto više o ovoj tehnici možete pročitati u knjizi


  1. Ili eventualno da napišete drajver za slike, što i nije tako teško kao što zvuči.↩︎

  2. Nije mnogo važno ka kojoj tački piksela je usmeren zrak. Važno je da je vektor usmeren unutar odgovarajućeg piksela.↩︎

  3. Pogledati objašnjenje o skalarnom proizvodu za više informacija.↩︎

  4. Johann Heinrich Lambert (1728–1777).↩︎

  5. Mat materijali se u stručnoj literaturi nazivaju difuzni.↩︎

  6. Ovo je idealno mesto da počnete da koristite klase za prezentovanje različitih vrsta objekata na sceni.↩︎

  7. Ako ste pristalica teorije ravne zemlje, definitivno bi trebalo da probate da implementirate ovaj način.↩︎

  8. Većina objekata koje vidimo u 3D animacijama i igricama, sačinjena je od velikog broja malih trouglova, baš kao što su slike sačinjene od piksela.↩︎

  9. Postoje i drugi modeli difuznih materijala. Za nas će biti dovoljan samo Lambertov model.↩︎

  10. Pierre de Fermat (1607–1665).↩︎

  11. Rasejanje je fenomen pri kom svetlosni zraci odstupaju od pravolinijske putanje zbog osobina sredine kroz koju prolaze. Jedna od posledica ovog fenomena jeste pojava sjaja oko svetlećih objekata.↩︎

  12. Trik sam ad hoc smislio za potrebe ovog teksta. Svestan sam da ima ograničenja, ali slike deluju sada realističnije te ću ga zadržati.↩︎

  13. Optički indeks sredine predstavlja odnos brzine svetlosti u toj sredini i brzine svetlosti u vakuumu.↩︎

  14. Willebrord Snellius (1580–1626).↩︎

  15. Ako bi ljudski vid reagovao linearno, tada bi svako udvostručenje intenziteta nadražaja uzrokovalo udvostručenje intenziteta percepcije tog nadražaja.↩︎

  16. Gustav Theodor Fechner (1801–1887).↩︎