Table of Contents

Laboratorul 04

Pentru rezolvarea cerințelor din acest laborator, aveți nevoie de codul utilizat în rezolvarea cerințelor din cadrul laboratorului 3. În situatia în care nu ați rezolvat laboratorul 3, va trebui să îl realizați mai întâi pe el și ulterior să reveniți la cerințele celui curent.

Reamintire!!! Puteți prezenta rezolvările cerințelor de până la 2 laboratoare, în fiecare săptămână. De exemplu, puteți prezenta laboratorul curent și pe cel din săptămâna anterioară, în totalitate sau parțial, inclusiv punctajul pentru cerința bonus :) .

Pentru rezolvarea cerințelor din cadrul acestui laborator:

  1. Descărcați framwork-ul de laborator și copiați, din arhiva descărcată, directorul Lab4, în interiorul directorului gfx-framework-ppbg\src\lab din versiunea voastră de proiect.
  2. Adăugați în fișierul lab_list.h, linia #include “lab/lab4/lab4.h”.
  3. Folosiți din nou utilitarul CMake pentru a regenera proiectul. Pentru a vă reaminti procesul de realizare a setup-ului, puteți să reconsultați pagina dedicată acestui lucru.

Aplicații grafice în timp real

Aplicațiile grafice în timp real efectuează o desenare succesivă a câte unui cadru. Între 2 cadre succesive, parametrii de desenare, precum poziția și direcția de vizualizare a observatorului, transformările geometriei desenate sau alte elemente ce influențează desenarea, cum sunt informațiile unei surse de lumină, pot să difere. Utilizarea unui număr mare de cadre desenate pe secundă creează iluzia de animație continuă :) .

În framework-ul pus la dispozitie în cadrul acestui laborator, succesiunea de cadre se realizează în clasa world, în metoda Run, printr-o buclă care se oprește doar în momentul în care se închide fereastra.

while (!window->ShouldClose())
{
    LoopUpdate();
}

Analiza unui cadru

În interiorul acestei bucle, în metoda LoopUpdate, se realizează următorul proces pentru fiecare cadru:

// 1. Polls and buffers the events
window->PollEvents();
 
// 2. Computes frame deltaTime in seconds
ComputeFrameDeltaTime();
 
// 3. Calls the methods of the instance of InputController in the following order
// OnWindowResize, OnMouseMove, OnMouseBtnPress, OnMouseBtnRelease, OnMouseScroll, OnKeyPress, OnMouseScroll, OnInputUpdate
// OnInputUpdate will be called each frame, the other functions are called only if an event is registered
window->UpdateObservers();
 
// 4. Frame processing
FrameStart();
Update(static_cast<float>(deltaTime));
FrameEnd();
 
// 5. Swap front and back buffers - image will be displayed to the screen
window->SwapBuffers();

Pașii din codul de mai sus sunt:

  1. Fereastra din interfața grafică a sistemului de operare este creată prin intermediul bibliotecii GLFW. Tot prin această bibliotecă, obținem evenimente externe aplicației care se pot produce la nivelul ferestrei, precum apăsarea de către utilizator a unei taste sau a unui buton de la mouse, redimensionarea ferestrei sau închiderea ei. Vom reveni asupra acestor evenimente mai jos.
  2. Se calculează timpul de desenare a cadrului anterior, denumit deltaTime.
  3. Se apelează metodele care tratează fiecare tip de eveniment extern ferestrei. Aceste metode se găsesc în fiecare clasă LabX și sunt discutate mai jos.
  4. Se apelează exact o dată o serie de metode ce se regăsesc în fiecare clasă LabX.
  5. Se blochează procesul curent de pe CPU pentru a se aștepta încheierea tuturor proceselor de desenare realizate de către procesorul grafic.

Interacțiunea cu utilizatorul

Interacțiunea utilizatorului cu fereastra poate fi apăsarea unei taste de la tastatură, apăsarea unui buton de la mouse, redimensionarea ferestrei sau închiderea ei.

Interacțiunea utilizatorului cu tastele, în situația în care fereastra este selectată, este de 3 feluri:

  1. Apăsarea unei taste pentru prima data în cadrul curent, cunoscută în limba engleză sub numele de key press.
  2. Neapăsarea unei taste la cadrul curent, în situația în care tasta a fost apăsată la cadrul anterior, cunoscută în limba engleză sub numele de key release.
  3. Apăsarea unei taste la cadrul curent, indiferent când a fost apasată prima dată, cunoscută în limba engleză sub numele de key hold.

Interacțiunea utilizatorului cu butoanele de la mouse este de 3 feluri, similar ca în situația tastelor, descrisă mai sus.

În framework-ul de laborator, interacțiunea cu utilizatorul se realizează în metodele OnWindowResize, OnMouseMove, OnMouseBtnPress, OnMouseBtnRelease, OnMouseScroll, OnKeyPress și OnMouseScroll, ce sunt apelate pentru fiecare eveniment realizat într-un cadru. De exemplu:

void Lab4::OnKeyPress(int key, int mods)
{
    if (key == GLFW_KEY_R) {
        printf("S-a apasat tasta R.");
    }
}

Metoda OnKeyPress se apelează pentru fiecare tastă apăsata într-un cadru. Trebuie verificat în interiorul metodei, pe baza parametrilor, ce tastă a fost apasată. Pentru mai multe informații despre fiecare metodă în parte, vă rog să citiți detaliile din fișierul header al clasei InputController.

Apelul fiecărei metode se realizează în cadrul pasului 3 descris mai sus.

Metoda OnInputUpdate are un statut special în cadrul framework-ului de laborator și este similară cu metoda Update, mai exact este apelată în fiecare cadru exact o dată. Apelul ei se realizează în cadrul pasului 3 de mai sus, astfel că se apelează înainte de metoda Update. Recomandarea este să utilizați această metodă când gestionați interacțiunea cu utilizatorul.

Suplimentar acestor metode specifice tratării interacțiunii cu utilizatorul, se poate folosi atributul window, intern clasei, pentru a verifica existența anumitor evenimente:

if(window->KeyHold(GLFW_KEY_R) {
    printf("Tasta R este apasata.");
}
 
if (window->MouseHold(GLFW_MOUSE_BUTTON_1)) {
    printf("Butonul stanga de la mouse este apasat.");
}

Animații independente de numărul de cadre desenate pe secundă

Modificarea proprietăților unui obiect între două cadre succesive trebuie realizată pe baza timpului care a trecut între cele două cadre, respectiv timpul trecut pentru desenarea cadrului anterior. Mai exact, în situația în care dorim să modificăm poziția unui obiect cu 5 unități de spațiu pe secundă, de-a lungul axei Z, în sens pozitiv, putem aplica următorul proces:

object_position.z += 5 * 0.016;

Valoarea 0.016 reprezintă timpul mediu de desenare a unui cadru la o frecvență de 60 de cadre pe secundă. Într-o secundă, poziția obiectului se va deplasa cu 5 unități de-a lungul axei Z, în sens pozitiv. Pentru a nu folosi direct valoarea aceasta, putem utiliza valoarea deltaTime, ce reprezintă timpul de desenare a cadrului precedent și este primită sub formă parametru în metodele Update și OnInputUpdate.

object_position.z += 5 * deltaTime;

API-ul grafic OpenGL

O aplicație grafică în timp real utilizeaza procesorul grafic pentru a accelera desenarea unui cadru și a obține o frecvență mare de desenare a cadrelor. Deoarece procesoarele grafice prezintă diferențe mari unele față de celelalte, pentru a se evita implementarea desenării specific pentru fiecare tip de procesor grafic, au fost introduse mai multe standarde de programare a procesorului. Aceste standarde sunt implementate în interiorul driver-ului video. O privire de ansamblu a comunicării între o aplicație grafică în timp real și procesorul grafic este prezentată în imaginea de mai jos. Aceste standarde poartă numele de API-uri grafice și pentru a enumera doar cateva: OpenGL, Direct3D sau Metal.

Toate API-urile realizează în mare aceleași operații standard în cadrul implementării hardware din procesorul grafic pentru realizarea prelucrărilor în banda grafică. Diferența este dată de forma de transmitere a parametrilor pentru aceste operații. În cadrul acestui laborator, noi vom utiliza API-ul grafic OpenGL pentru programarea prelucrărilor în banda grafică.

Curățarea informației de pe ecran

Primul pas realizat la începutul unui cadru este curățarea grilei de pixeli și a grilei de valori de adâncime desenate la cadrul anterior. Acest proces se realizează în API-ul grafic OpenGL prin directiva:

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

Informația din fiecare celulă a grilei de valori de adâncime se suprascrie cu valoarea 1. Aceasta reprezintă valoarea componentei z a feței din spate pentru volumul de decupare. Culoarea cu care se suprascrie fiecare celulă din grila de pixeli se poate stabili prin:

glClearColor(0, 0, 0, 1);

Poarta de afișare

Se poate stabili zona din ecran, poarta de afișare, în care să se deseneze obiectele prin directiva:

glViewport(0, 0, 1280, 720);

Modele 3D

Un model 3D, cunoscut în limba engleză sub numele de 3D mesh, este un obiect tridimensional definit prin vârfuri și indici. În laborator aveți posibilitatea să încărcați modele 3D în aproape orice format posibil prin intermediul clasei Mesh.

Vertex Buffer Object (VBO)

Un vertex buffer object reprezintă un container în care stocăm date ce țin de conținutul vârfurilor precum:

Un vertex buffer object se poate crea prin comanda OpenGL glGenBuffers:

unsigned int VBO_ID;		// ID-ul (nume sau referinta) buffer-ului ce va fi cerut de la GPU
glGenBuffers(1, &VBO_ID);	// se genereaza ID-ul (numele) bufferului

Așa cum se poate vedea și din explicația API-ului, funcția glGenBuffers primește numărul de buffere ce trebuie generate cât și locația din memorie unde vor fi salvate referințele (ID-urile) generate.
În exemplul de mai sus este generat doar 1 singur buffer iar ID-ul este salvat în variabila VBO_ID.

Pentru a distruge un VBO și astfel să eliberăm memoria de pe GPU se folosește comanda glDeleteBuffers:

glDeleteBuffers(1, &VBO_ID);

Pentru a putea pune date într-un buffer trebuie întâi să legăm acest buffer la un „target”. Pentru un vertex buffer acest „binding point” se numește GL_ARRAY_BUFFER și se poate specifica prin comanda glBindBuffer:

glBindBuffer(GL_ARRAY_BUFFER, VBO_ID);

În acest moment putem să facem upload de date din memoria CPU către GPU prin intermediul comenzii glBufferData:

glBufferData(GL_ARRAY_BUFFER, sizeof(vertices[0]) * vertices.size(), &vertices[0], GL_STATIC_DRAW);

Pentru a înțelege mai bine API-ul OpenGL vă recomandăm să citiți documentația indicată pentru fiecare comandă prezentată. Atunci când se prezintă o nouă comandă, dacă apăsați click pe numele acesteia veți fi redirecționați către pagina de manual a comenzii respective.
De asemenea, documentația oficială și completă a API-ului OpenGL poate fi gasită pe pagina OpenGL 4 Reference Pages

Index Buffer Object (IBO)

Un index buffer object (numit și element buffer object) reprezintă un container în care stocăm indicii vertecșilor. Cum VBO si IBO sunt buffere, ele sunt extrem de similare în construcție, încărcare de date și ștergere.

glGenBuffers(1, &IBO_ID);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, IBO_ID);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices[0]) * indices.size(), &indices[0], GL_STATIC_DRAW);

La fel ca la VBO, creăm un IBO și apoi îl legăm la un punct de legatură, doar că de data aceasta punctul de legatură este GL_ELEMENT_ARRAY_BUFFER. Datele sunt trimise către bufferul mapat la acest punct de legatură. În cazul indicilor toți vor fi de dimensiunea unui singur întreg.

Vertex Array Object (VAO)

Într-un vertex array object putem stoca toată informația legată de starea geometriei desenate. Putem folosi un număr mare de buffere pentru a stoca fiecare din diferitele atribute („separate buffers”). Putem stoca mai multe (sau toate) atribute într-un singur buffer („interleaved” buffers). În mod normal înainte de fiecare comandă de desenare trebuie specificate toate comenzile de „binding” pentru buffere sau atribute ce descriu datele ce doresc a fi randate. Pentru a simplifica această operație se folosește un vertex array object care ține minte toate aceste legături.

Un vertex array object este creat folosind comanda glGenVertexArrays:

unsigned int VAO;
glGenVertexArrays(1, &VAO);

Este legat cu glBindVertexArray:

glBindVertexArray(VAO);

Înainte de a crea VBO-urile și IBO-ul necesar pentru un obiect se va lega VAO-ul obiectului și acesta va ține minte automat toate legăturile specificate ulterior.

După ce toate legăturile au fost specificate este recomandat să se dea comanda glBindVertexArray(0) pentru a dezactiva legătura către VAO-ul curent, deoarece altfel riscăm ca alte comenzi OpenGL ulterioare să fie legate la același VAO și astfel să introducem foarte ușor erori în program.

Înainte de comanda de desenare este suficient să legăm doar VAO-ul ca OpenGL să știe toate legatările create la construcția obiectului.

Opțiunea de optimizare Face Culling

API-ul OpenGL oferă posibilitatea de a testa orientarea aparentă pe ecran a fiecărui triunghi înainte ca acesta să fie redat și să îl ignore în funcție de starea de discard setată: GL_FRONT sau GL_BACK. Acestă funcționalitate poartă numele de Face Culling și este foarte importantă deoarece reduce costul de procesare total.

Modul cum este considerată o față ca fiind GL_FRONT sau GL_BACK poate fi schimbat folosind comanda glFrontFace (valoarea inițială pentru o față GL_FRONT este considerată ca având ordinea specificării vârfurilor în sens trigonometric / counter clockwise):

// mode can be GL_CW (clockwise) or GL_CCW (counterclockwise)
// the initial value is GL_CCW
void glFrontFace(GLenum mode​);

Exemplu: pentru un cub maxim 3 fețe pot fi vizibile la un moment dat din cele 6 existente. În acest caz maxim 6 triunghiuri vor fi procesate pentru afișarea pe ecran în loc de 12.

În mod normal face-culling este dezactivat. Acesta poate fi activat folosind comanda glEnable:

glEnable(GL_CULL_FACE);

Pentru a dezactiva face-culling se folosește comanda glDisable:

glDisable(GL_CULL_FACE);

Pentru a specifica ce orientare a fețelor să fie ignorată se folosește comanda glCullFace

// GL_FRONT, GL_BACK, and GL_FRONT_AND_BACK are accepted.
// The initial value is GL_BACK.
glCullFace(GL_BACK);

Cerințe laborator

Pentru toate cerințele în care se precizează că animațiile trebuie să fie continue, utilizați valoarea deltaTime.

  1. 0.05p - Completați metoda CreateMesh() astfel încât să încărcați geometria in memoria RAM a procesorului grafic.
    • Creați un VAO
    • Creați un VBO și adăugați date în el
    • Creați un IBO și adăugați date în el
    • După completarea corectă a metodei, rezultatul vizual ar trebui să fie următorul:
  2. 0.05p - La apăsarea unei taste, alegeți o culoare aleatorie pentru curățarea grilei de pixeli.
    • Utilizați în metoda Update(), directiva de specificare a culorii de curățare a grilei de pixeli.
    • La apăsarea tastei R, alegeți o culoare aleatorie.
  3. 0.05p - La apăsarea unei taste, schimbați între desenarea triunghiurilor a căror fațetă față sau spate se afișează.
    • Utilizați în metoda DrawObjects(), directivele de activare și dezactivare a optimizării Face culling.
    • Utilizați în metoda DrawObjects() directiva de specificare a tipului de față pentru care triunghiurile se elimina din procesul de rasterizare.
    • La apăsarea tastei F, schimbați între eliminarea triunghiurilor pentru care se afișează fațeta față sau spate.
  4. 0.05p - Pentru cele 3 cuburi din scenă, aplicați urmatoarele animații:
    • Unul dintre cuburi să se deplaseze continuu sus-jos între limitele 0 și 3 de-a lungul axei Y.
    • Un alt cub să se rotească continuu față de una dintre cele 3 axe principale.
    • Un alt cub sa pulseze continuu între scările 0.5 și 2.
  5. 0.05p - Desenați un alt cub pe care să îl deplasați prin spațiu la apăsarea tastelor W, A, S, D, E, Q (pozitiv și negativ pe toate cele 3 axe). Nu permiteți deplasarea cubului în situația în care poziția observatorului se modifică.
  6. 0.05p - Desenați obiectele de 4 ori în 4 porți de afișare diferite, conform imaginii de mai jos. Păstrați proporțiile precizate în imagine.
    • Utilizați în metoda DrawObjects(), directiva de specificare a poziției și dimensiunii porții de afișare.
    • În metoda Update(), desenați de 4 ori obiectele. Pentru fiecare desenare a obiectelor, utilizați una dintre cele 4 camere setate cu poziții și direcții de vizualizare predefinite.