Tuesday, Jul 18, 2017
MSCommunity predavanje: Uvod u razvoj 3D aplikacija
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
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.
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);
}
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).
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
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.
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