MSCommunity predavanje: Uvod u razvoj 3D aplikacija
Design | Luka Erkapić

MSCommunity predavanje: Uvod u razvoj 3D aplikacija

Saturday, Jan 14, 2017 • 7 min read
Što je 3D renderiranje, kako radi grafička kartica, te kako dobivamo 3D prikaz na računalu - neka su od pitanja na koja smo pokušali odgovoriti na MSCommunity predavanju.

U Osijeku je nedavno održano 75. po redu MSCommunity predavanje, koje je obuhvatilo dvije teme: “Razvoj videoigara korištenjem Unity enginea” i “Uvod u razvoj 3D aplikacija”. Cilj je bio objasniti osnovne koncepte razvoja igara, te osnove 3D renderiranja. Ovaj tekst prolazi kroz drugo, osnove 3D renderiranja.

Još jedno

3D renderiranje je proces pretvorbe trodimenzionalnog (3D) objekta u sliku ili skup slika (video) uz pomoć računalnog programa. Da bi se uopće moglo govoriti o 3D renderiranju, potrebno je upoznati osnovnu strukturu podatka u 3D prostoru - u ovom slučaju vrh (engl. vertex) ili vektor. Vrh je, jednostavno rečeno, točka u 3D prostoru. Grafička kartica obavlja mnoštvo poslova - od tih vrhova pravi modele i u konačnici prikazuje 2D sliku, a svi ti poslovi čine pipeline grafičke kartice.

Pipeline grafičke kartice

enlightenment

Izvor: enlightenment.org

Prikaz na slici iznad je pojednostavljen, a faze se mogu podijeliti na sljedeće:

Vertex stream

Faza učitavanja vrhova ili vektora na grafičku karticu.

Shader vrhova

Pojam shader će biti objašnjen kasnije, ali ukratko rečeno, to je program koji se izvodi na grafičkoj kartici, a kojim definiramo kako će vrhovi biti prikazani. Primjerice, u shaderu vrhova možemo definirati matricu transformacije, te raditi operacije na vrhovima poput rotacije, koja bi za učinak imala rotaciju samog 3D modela.

Triangle assembly

Proces u kojem se od definiranih vrhova (iz faze prije) sastavlja primitive (trokut, linija, točka) za prikaz. Sam model je sastavljen od mnoštva vrhova, koji najčešće čine trokute.

OpenClipart

Izvor: cs.rpi.edu

Rasterization

Faza rasterizacije - pretvara definirane vrhove, odnosno trokute ili linije, u piksele za 2D prikaz.

Shader piksela

Izvodi operacije na pikselima koji dolaze iz faze rasterizacije (u OpenGL-u se koristi naziv fragment shader). To su operacije poput dodavanja boja, osvjetljenja ili dodavanja sjena.

Framebuffer

Međuspremnik na grafičkoj kartici; mjesto u memoriji za spremanje i prikaz same slike. Najčešće se govori o dva framebuffera: backbuffer, na kojemu se slika iscrtava i priprema za prikaz, te frontbuffer, koji prikazuje trenutnu sliku.

Shaderi

Shaderi su programi koji se izvode na grafičkoj kartici, a postoje:

  • Shader vrhova koji se u grafičkom pipeline-u izvodi prvi. On obrađuje pojedinačne vrhove, te ih, na primjer, transformira (određuje im poziciju i rotaciju).
  • Shader geometrije izvodi se nakon shadera vrhova. Može, a i ne mora biti definiran. Ovaj shader procesira same ‘primitive’ - točke, linije ili trokute, te im može dodavati nove vrhove ili ih transformirati, te tako napraviti drugi geometrijski oblik.
  • Shader piksela je treći i zadnji shader u pipeline-u. Procesira same piksele ili fragmente. Ulazne varijable ovog shadera su izlazne varijable iz shadera vrhova ili shadera geometrije.

Dalje ćemo kratko objasniti shader vrhova i shader piksela.

Shader vrhova

Ovaj shader obavlja sve poslove manipulacije vrhovima, poput transformacije koordinata, transformacije koordinata tekstura, transformacije normala (vektori za računanje svjetlosti), itd.

Ulazne vrijednosti u shader vrhova su vrhovi, odnosno vektori. Sve ulazne vrijednosti se definiraju uz ključnu riječ in (ili attribute u starijim verzijama shadera).

Izlazne vrijednosti koriste ključnu riječ out (u starijim verzijama varying). Izlazne vrijednosti shadera vrhova su ulazne vrijednosti shadera piksela.

Također postoje i uniform varijable koje služe za komunikaciju između CPU-a (central processing unit) i GPU-a (graphics processing unit). Vrijednosti uniform varijabli se šalju sa CPU-a na GPU, gdje se njihova vrijednost može samo isčitati.

#version 400

// Ulazne varijable su pozicija svakog vrha i njihova boja
in vec3 a_vertices;
in vec3 a_colors;

// 'Uniform' varijable. Ostaju iste tijekom izračuna svakog vrha
uniform mat4 u_projectionMatrix;
uniform mat4 u_viewMatrix;
uniform mat4 u_transformationMatrix;

// Izlazna varijabla, koja će biti ulazna varijabla shader piksela
out vec3 colors;

void main()
{
	// gl_Position je interna varijabla, kojoj dodajemo konačni izračun vrha
	gl_Position = u_projectionMatrix * u_viewMatrix * u_transformationMatrix * vec4(a_vertices, 1);

	colors = a_colors;
}

Shader piksela

Ovaj shader obrađuje piksele koji su generirani u fazi rasterizacije. Svaki prolazi kroz piksel shader kako bi se dobila njegova konačna boja.

Ulazni podaci su obično koordinate teksture, boja piksela, vektori za osvjetljenje, itd. Izlazni podatak je uvijek konačna boja piksela.

#version 400

in vec3 colors;

out vec4 final_color;

void main()
{
	final_color = vec4(colors, 1.0);
}

Predavanje Izvor: jimmysoftware.net

OpenTK

OpenTK je C# wrapper za OpenGL, što znači da pozive iz OpenGL-a možemo koristiti uz C# programski jezik.

Za primjer će se koristiti Visual Studio. U Visual Studio editoru potrebno je napraviti projekt konzolne aplikacije, te instalirati OpenTK nugget. Nakon što je paket instaliran, potrebno je napraviti novu klasu. Nazovimo ju Game.

...
using OpenTK;
using OpenTK.Graphics.OpenGL4;

namespace OpenTKExample
{
    public class Game : GameWindow
    {
    }
}

Ova klasa nasljeđuje GameWindow klasu, koja će pokrenuti prozor za renderiranje. U klasu Program potrebno je dodati sljedeće:

...
static void Main(string[] args)
{
    using (var game = new Game())
    {
        game.Run();
    }
}

Ono što je specifično za OpenGL je to da se ponaša kao state machine, te za kreirane objekte vraća samo id, koji je tipa int. Prvo što treba kreirati je OpenGL program (objekt na koji vežemo shader), a zatim i same shadere. Shaderi u OpenGLu imaju ekstenziju .glsl, pa tako u projekt dodajemo dvije nove datoteke: vertexShader.glsl (shader vrhova) i fragmentShader.glsl (shader piksela). Na samoj glsl datoteci potrebno je promijeniti svojstvo kopiranja datoteke (postavite opciju Copy to output directory na Copy if newer).

Predavanje

Vertex shader ili shader vrhova:

#version 400

void main()
{
	gl_Position = vec4(0,0,0,1);
}

Fragment shader ili shader piksela:

#version 400

out vec4 final_color;

void main()
{
	final_color = vec4(1,0,0, 1.0);
}

Game klasa:

...
public class Game : GameWindow
{
	private int programId;

	protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);
    }

	protected override void OnRenderFrame(FrameEventArgs e)
    {
        base.OnRenderFrame(e);
    }

OnLoad metoda se u programu izvršava jednom, dok se OnRenderFrame poziva onoliko puta koliko to grafička kartica dopušta - ukoliko ne ograničimo broj sličica u sekundi (FPS). U OnLoad metodi ćemo učitati shadere te ih vezati uz program. Većina operacija koja se treba izvršiti jednom, poput učitavanja tekstura, shadera i modela, bi se trebala izvršiti u toj metodi.

protected override void OnLoad(EventArgs e)
{
    base.OnLoad(e);

	// Učitaj shadere kao tekst
 	string vertexShaderSource = File.ReadAllText("vertexShader.glsl");
    string fragmentShaderSource = File.ReadAllText("fragmentShader.glsl");

	// Kreiraj program na koji se vežu shaderi
    programId = GL.CreateProgram();
	
	// Kreiraj shader vrhova. Potom ga je potrebno kompajlirati i vezati za program.
    int vertexShaderId = GL.CreateShader(ShaderType.VertexShader);
    GL.ShaderSource(vertexShaderId, vertexShaderSource);
	GL.CompileShader(vertexShaderId);
    Console.WriteLine(GL.GetShaderInfoLog(vertexShaderId));
    GL.AttachShader(programId, vertexShaderId);

	// Shader piksela
    int fragmentShaderId = GL.CreateShader(ShaderType.FragmentShader);
    GL.ShaderSource(fragmentShaderId, fragmentShaderSource);
    GL.CompileShader(fragmentShaderId);
    Console.WriteLine(GL.GetShaderInfoLog(fragmentShaderId));
    GL.AttachShader(programId, fragmentShaderId);

	// Vezanje programa za OpenGL
    GL.LinkProgram(programId);

	// Definira boju pozadine, odnosno boju 'framebuffera' 
    GL.ClearColor(1, 1, 0, 1);
}

protected override void OnRenderFrame(FrameEventArgs e)
{
    base.OnRenderFrame(e);

	// Očisti 'framebuffer' od svih boja (ukoliko se ovo ne pozove u framebuffer-u ostaju boje iz prošlog poziva)
    GL.Clear(ClearBufferMask.ColorBufferBit);

	// Zamijeni 'framebuffere'
    SwapBuffers();
}

Ono što je bitno napraviti je očistiti framebuffer, odnosno obrisati podatke iz prošlog poziva. SwapBuffers metoda mijenja frontbuffer i backbuffer. Uloga frontbuffera je prikazati samu sliku na sučelju, dok backbuffer iscrtava tu sliku. Da nema dva framebuffera morali bi gledati samo iscrtavanje slike.

Kada se program pokrene, trebala bi biti iscrtana žuta pozadina.

Za iscrtavanje nekog objekta, potrebno je definirati vrhove. Prvo u shaderu vrhova (vertexShader) treba definirati varijablu u koju će se oni spremiti. Već je spomenuto da se da ulazna varijabla definira uz ključnu riječ in i, u ovom slučaju, to će biti vektor s koordinatama X i Y.

Bitno je spomenuti da koordinatni sustav OpenGLa ima ishodište točno u sredini, pozitivna X os gleda udesno, pozitivna Y os prema gore, a pozitivna Z os prema natrag.

OpenGL koordinatni sustav

Cocos2D

Izvor: cocos2d-x.org

#version 400

in vec2 a_position;

void main()
{
	// -1 za Z os. 
	gl_Position = vec4(a_position,-1,1);
}

U sljedećem koraku šaljemo podatke na GPU. Za početak nam treba varijabla koja će imati adresu a_position varijable iz shadera. U Game klasi dodajemo novu varijablu positionLocationAttribute i metodu BufferData. Metodu pozivamo odmah iza LinkProgram poziva.

...
private int programId;
private int positionLocationAttribute;
...

protected override void OnLoad(EventArgs e)
{
    ...  
    GL.LinkProgram(programId);

    BufferData();

    GL.ClearColor(1, 1, 0, 1);
}

protected override void OnRenderFrame(FrameEventArgs e)
{
    base.OnRenderFrame(e);

    GL.Clear(ClearBufferMask.ColorBufferBit);

	// Koji program se koristi i koji atributi
    GL.UseProgram(programId);
    GL.EnableVertexAttribArray(vertexAttributeLocation);

    GL.DrawArrays(PrimitiveType.Triangles, 0, 3);

    SwapBuffers();
}

private void BufferData()
{
	// Vrhovi trokuta
    Vector2[] vertices = new Vector2[]
    {
        new Vector2(-.5f, 0f),
        new Vector2(0f, .8f),
        new Vector2(.5f, 0f)
    };

	// Adresa iz shadera
	vertexAttributeLocation = GL.GetAttribLocation(programId, "a_position");

	// Potrebno je kreirati mjesto u memoriji grafičke kartice gdje će vrhovi biti pohranjeni
    int vertexBuffer = GL.GenBuffer();
    GL.BindBuffer(BufferTarget.ArrayBuffer, vertexBuffer);
    GL.BufferData<Vector2>(BufferTarget.ArrayBuffer, (IntPtr)(vertices.Length * Vector2.SizeInBytes), vertices, BufferUsageHint.StaticDraw);
    GL.VertexAttribPointer(vertexAttributeLocation, 2, VertexAttribPointerType.Float, false, 0, 0);
}

GetAttribLocation vraća id varijable iz shadera - ako je id manji od 0, varijabla nije pronađena.

GenBuffer kreira mjesto u memoriji. BindBuffer veže to mjesto za OpenGL program koji je trenutno u upotrebi, a program možemo promijeniti upotrebom UseProgram metode. Potom je potrebno same podatke učitati u memoriju grafičke kartice, a to se postiže BufferData metodom. Metoda VertexAttribPointer veže varijablu iz shadera s učitanim podacima, te opisuje same podatke.

U OnRenderFrame metodi je potrebno postaviti program koji se koristi, te varijable. Postoji više ‘primitiva’ koje možemo crtati, a u ovom slučaju odlučili smo se za trokute i to prosljeđujemo DrawArrays metodi. Drugi parametar je broj vrhova koje preskačemo - u ovom slučaju ne preskačemo ni jedan. Zadnji parametar je broj vrhova koje prosljeđujemo na shader.

Kada se program pokrene, trebao bi biti iscrtan crveni trokut. Sama boja trokuta je definirana u shaderu piksela (datoteka `fragmentShader.glsl).

...
void main()
{
	final_color = vec4(1,0,0, 1.0);
}

Učitavanje boja za vrhove je slično učitavanju samih vrhova. Prvo u shaderima treba definirati ulazne i izlazne varijable.

#version 400

in vec2 a_position;
in vec3 a_color;

out vec3 color;

void main()
{
	gl_Position = vec4(a_position, -1, 1);
	color = a_color;
}
#version 400

in vec3 color;

out vec4 final_color;

void main()
{
	final_color = vec4(color, 1.0);
}

Kao i kod definiranja vrhova, trebamo varijablu koja će predstavljati adresu a_colors varijable iz shadera.

...
private int programId;
private int vertexAttributeLocation;
private int colorAttributeLocation;

...

protected override void OnRenderFrame(FrameEventArgs e)
{
	...

    GL.UseProgram(programId);
    GL.EnableVertexAttribArray(vertexAttributeLocation);
	GL.EnableVertexAttribArray(colorAttributeLocation);

	...
}

private void BufferData()
{
	...

	Vector3[] colors = new Vector3[]
    {
        // crvena boja
        Vector3.UnitX,
        // zelena boja
        Vector3.UnitY, 
        // plava boja
        Vector3.UnitZ
    };
    colorAttributeLocation = GL.GetAttribLocation(programId, "a_color");

	int colorBuffer = GL.GenBuffer();
    GL.BindBuffer(BufferTarget.ArrayBuffer, colorBuffer);
    GL.BufferData<Vector3>(BufferTarget.ArrayBuffer, (IntPtr)(colors.Length * Vector3.SizeInBytes), colors, BufferUsageHint.StaticDraw);
    GL.VertexAttribPointer(colorAttributeLocation, 3, VertexAttribPointerType.Float, false, 0, 0);
}

Nakon pokretanja programa, trebao bi biti iscrtan trokut sa zelenom, crvenom i plavom bojom vrhova.

Primjer

Uniform varijable

Za definiranje ovih varijabli, u shaderu se koristi ključna riječ uniform, npr.:

...
uniform mat4 u_transformationMatrix;

void main()
{
	gl_Position =  u_transformationMatrix * vec4(a_position, -1, 1);
	...
}

Transformacijska matrica će nam u ovom primjeru poslužiti za rotaciju trokuta.

Uniform varijable se mogu samo iščitati i njihova vrijednost se ne može mijenjati u shaderu. Prvo moramo pronaći adresu matrice iz shadera, a to radimo uz pomoć GetUniformLocation metode.

...
private float angle;
private int transformationMatrixLocation;
private Matrix4 transformationMatrix;

protected override void OnLoad(EventArgs e)
{
    ...   
    BufferData();

    transformationMatrixLocation = GL.GetUniformLocation(programId, "u_transformationMatrix");

    GL.ClearColor(1, 1, 0, 1);
}

Nakon što imamo adresu matrice, potrebno je poslati njenu vrijednost.

protected override void OnRenderFrame(FrameEventArgs e)
{
	...
    angle += .01f;
    transformationMatrix = Matrix4.Identity * Matrix4.CreateRotationZ(angle);
    GL.UniformMatrix4(transformationMatrixLocation, false, ref transformationMatrix);

    GL.DrawArrays(PrimitiveType.Triangles, 0, 3);

}

U konačnici bi trebali imati trokut koji se rotira.

Ostale primjere s predavanja možete pronaći na MSCommunity OpenTK.

Naslovna fotografija: DEV UG - mscommunity.hr