ทุกอย่างที่คุณเคยเห็นวาดขึ้นมาบนหน้าจอในเกม เกิดขึ้นเพราะโปรแกรมบน CPU เรียก graphics API ซ้ำ ๆ หลายครั้งต่อหนึ่งเฟรม เพื่อบอก GPU ว่าต้องวาดอะไรและวาดยังไงแบบเป๊ะ ๆ ใน Unity คุณไม่เคยเห็น call พวกนี้เลย เพราะเอนจินเรียกให้คุณ บทนี้จะเปิดฝาให้ดูข้างใน คุณจะเริ่มเรียนรู้ OpenGL ซึ่งเป็น API กราฟิกที่ง่ายที่สุดในสามตัวจริง ๆ ที่ใช้กันในอุตสาหกรรม โดยการสร้างสามเหลี่ยมที่ทำงานได้จริงจากชิ้นส่วนดิบ ๆ ได้แก่ buffer ของข้อมูล vertex, คำอธิบาย layout ของข้อมูลนั้น, โปรแกรมเล็ก ๆ ที่คอมไพล์แล้วสองตัวเรียกว่า shader, และ function call หนึ่งตัวที่สุดท้ายจะวาดอะไรบางอย่างออกมาจริง ๆ จากนั้นคุณจะเห็นว่าทำไมวงการถึงย้ายออกจาก OpenGL ไปใช้ Vulkan กับ DirectX 12 เป็นส่วนใหญ่ และเรียนรู้รูปร่างของ API สองตัวนั้น ซึ่งเป็นรูปร่างที่โผล่มาในบทสัมภาษณ์งานเอนจินจริง ๆ
โค้ดเกมของคุณรันอยู่บน CPU ส่วนพิกเซลจริง ๆ บนหน้าจอถูกสร้างโดย GPU (Graphics Processing Unit — โปรเซสเซอร์แยกต่างหากที่สร้างจากคอร์เล็ก ๆ นับพันตัว ออกแบบมาให้ทำเลขง่าย ๆ ชุดเดียวกันกับข้อมูลจำนวนมหาศาลพร้อมกัน แทนที่จะทำงานซับซ้อนไม่กี่อย่างทีละอย่างแบบที่คอร์ CPU ทำ) โค้ด CPU ของคุณไม่สามารถไปจิ้มหน่วยความจำของ GPU โดยตรง หรือสั่งให้คอร์ GPU รันอะไรได้เอง มันต้องมีข้อตกลงร่วมกันให้ทั้งสองฝ่ายคุยกันได้ ข้อตกลงนั้นคือ graphics API (Application Programming Interface — ชุด function และกฎที่ตายตัว ที่ซอฟต์แวร์สองชิ้นตกลงใช้คุยกัน)
เวลาคุณเรียก graphics API function คุณไม่ได้คุยกับชิป GPU โดยตรง แต่คุณกำลังคุยกับ driver (ซอฟต์แวร์ที่เขียนโดยผู้ผลิต GPU — NVIDIA, AMD, Intel, Apple, Qualcomm — ที่แปล API call ของคุณให้กลายเป็นคำสั่งเครื่องจริง ๆ ที่ GPU ตัวนั้นเข้าใจ) GPU จากผู้ผลิตต่างกันมีดีไซน์ฮาร์ดแวร์ข้างในต่างกันมาก แต่ทุกตัวสามารถเปิด function ของ OpenGL, Vulkan หรือ DirectX 12 แบบเดียวกันได้ เพราะ driver คือตัวที่ซ่อนความต่างของฮาร์ดแวร์เอาไว้
มี graphics API สามตัวที่สำคัญต่อสายอาชีพ game programmer ในวันนี้
บทนี้จะสอนออบเจกต์หลัก ๆ โดยใช้ OpenGL ก่อน เพราะ function call ของมัน map ตรงเข้ากับแนวคิดพื้นฐาน (buffer, shader, texture, draw call) โดยไม่มีพิธีรีตองเพิ่มเติม พอแนวคิดพวกนี้แน่นแล้ว Vulkan กับ DirectX 12 จะไม่ดูเป็นภาษาต่างดาวอีกต่อไป มันคือแนวคิดเดียวกัน แค่ทำแบบ explicit แทนที่จะทำอัตโนมัติ
API ทั้งสามตัวมีอยู่เพื่อทำงานเดียวกัน คือเอาข้อมูล vertex กับโปรแกรม shader ขึ้นไปบน GPU แล้วสั่ง draw call ที่เปลี่ยนสามเหลี่ยมให้กลายเป็นพิกเซลมีสี ความต่างอยู่ที่ driver ทำงานเบื้องหลังให้คุณมากแค่ไหน เทียบกับที่คุณต้องเขียนสั่งเองมากแค่ไหน
OpenGL เป็น state machine (ระบบที่จำชุดค่าปัจจุบันไว้ — เช่น "buffer ที่ bind อยู่ตอนนี้", "shader program ที่ active อยู่ตอนนี้" — และทุก function call จะอ่านหรือเปลี่ยนค่าที่จำไว้เหล่านั้น แทนที่คุณจะส่ง parameter ทุกตัวแบบ explicit ทุกครั้ง) แบบนี้ทำให้ OpenGL เขียนสั้น แต่ก็แปลว่า driver ต้องเดาเยอะ มันต้อง re-validate state เกือบทุก call และจัดการ memory, threading, synchronization ให้คุณอยู่เบื้องหลัง งานที่ซ่อนอยู่นี้กิน CPU time ทุก ๆ draw call
Vulkan กับ DirectX 12 ตัดงานที่ซ่อนอยู่พวกนี้ออกไปเกือบหมด คุณอธิบาย rendering setup ของคุณครั้งเดียวแบบละเอียดล่วงหน้า แล้ว driver จะ validate มันครั้งเดียว ไม่ใช่ทุกเฟรม คุณจัดการ GPU memory เอง คุณ record งานจากหลาย CPU thread พร้อมกันได้ และที่สำคัญคือคุณต้องเป็นคนบอกเองว่างานชิ้นหนึ่งต้องรอชิ้นไหนก่อน แทนที่ driver จะแอบใส่ wait "เผื่อไว้ก่อน" Section 9 จะอธิบายว่าทำไมการแลกเปลี่ยนนี้ถึงคุ้มค่า
ก่อนเขียนโค้ด ควรเห็นภาพรวมทั้งหมดก่อน งานของ OpenGL ถูกสร้างจากออบเจกต์ไม่กี่ประเภท และคุณเชื่อมมันเข้าด้วยกันด้วยการ bind (ทำให้ออบเจกต์เป็น "current" เพื่อให้ function call ถัดไปทำงานกับมันโดยปริยาย) พอคุณเห็นกราฟนี้ในหัวได้ ทุก OpenGL function call จะกลายเป็นแค่ "กำลัง bind ออบเจกต์ไหนอยู่ และกำลังสั่งอะไรมัน"
ออบเจกต์ห้าแบบ ห้าหน้าที่
ห้า section ถัดไปจะสร้างแต่ละอย่างในโค้ด ทีละตัว ก่อนที่ Section 8 จะเอาทั้งหมดมาต่อกันเป็นโปรแกรมที่ทำงานได้จริงตัวเดียว
Vertex คือจุดมุมหนึ่งของรูปทรงที่คุณกำลังวาด ปกติมันมี position และมักมีข้อมูลอื่นด้วย (สี, texture coordinate, ทิศทาง normal) สามเหลี่ยมต้องการสาม vertex ก่อนที่ GPU จะวาดอะไรได้ ข้อมูล vertex นั้นต้องอยู่ในหน่วยความจำ GPU ไม่ใช่แค่ CPU RAM เฉย ๆ เพราะ GPU อ่าน heap หรือ stack ปกติของโปรแกรมคุณโดยตรงไม่ได้
float vertices[] = {
// x y z
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
unsigned int vao, vbo;
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glBindVertexArray(0);
คุณรู้จัก & จากบท C มาแล้ว มันคือการเอา address ของตัวแปร glGenVertexArrays(1, &vao) สั่งให้ OpenGL สร้าง VAO ใหม่ 1 ตัว แล้วเขียน ID ของมันตรงเข้าไปในตัวแปร vao ของคุณ ผ่าน pointer นั้น แทนที่จะ return ค่ากลับมา function ประเภท "create" ของ OpenGL เกือบทุกตัวมีรูปแบบเดียวกันนี้
ไล่ดูที่เหลือ ทีละ call
glBindVertexArray(vao) — ทำให้ vao เป็น VAO "current" ทุก call ที่เกี่ยวกับ attribute ข้างล่างนี้จะเขียนลงในความจำของ VAO ตัวนี้ ว่าจะอ่านข้อมูลยังไงglBindBuffer(GL_ARRAY_BUFFER, vbo) — ทำให้ vbo เป็น buffer ปัจจุบันสำหรับ target GL_ARRAY_BUFFER (state machine ของ OpenGL อีกแล้ว นี่คือช่อง "buffer ปัจจุบันสำหรับจุดประสงค์นี้" ไม่ใช่การอ้างอิงตรง ๆ)glBufferData(...) — copy sizeof(vertices) byte จาก array บน CPU ของคุณเข้าไปในหน่วยความจำ GPU ของ buffer ที่ bind อยู่ GL_STATIC_DRAW เป็น hint บอก driver ว่า "ฉันจะอัปโหลดครั้งเดียวแล้ววาดหลายรอบ" ซึ่งทำให้ driver เลือกหน่วยความจำที่เร็วกว่าให้ได้glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0) — อธิบายวิธีอ่าน attribute slot 0 คือ 3 float ต่อ vertex, ไม่ normalize, ห่างกัน 3 float (12 byte) จาก vertex หนึ่งไปอีก vertex หนึ่ง (เรียกว่า stride) เริ่มที่ byte offset 0glEnableVertexAttribArray(0) — เปิด attribute slot 0 ถ้าไม่เรียก call นี้ GPU จะไม่สนใจ buffer สำหรับ slot นั้นเลยglVertexAttribPointer กับ glEnableVertexAttribArray โดยไม่มี VAO ถูก bind อยู่ หรือลืมเรียก glEnableVertexAttribArray ไปเลย ถ้า attribute ไม่ถูกเปิดใช้ GPU จะอ่านค่า default คงที่ (0, 0, 0, 1) สำหรับทุก vertex แทนข้อมูลใน buffer ของคุณ ทุก vertex จะซ้อนทับกันที่จุดเดียวกัน และสามเหลี่ยมพื้นที่เป็นศูนย์จะไม่มีอะไรให้เห็นเลย บั๊กแบบนี้เป๊ะ ๆ คือ Exercise 1 ข้างล่างสามเหลี่ยมใช้แค่สาม vertex ก็พอ แต่ mesh จริง ๆ มักใช้ vertex ซ้ำ เช่นสี่เหลี่ยมที่มีสองสามเหลี่ยมแชร์มุมกันสองมุม แทนที่จะ duplicate vertex ที่แชร์กันนั้น คุณสามารถเก็บแต่ละ vertex ที่ไม่ซ้ำกันแค่ครั้งเดียว แล้วเพิ่ม EBO (Element Buffer Object เรียกอีกชื่อว่า index buffer) เข้าไป คือ list ของเลข integer บอกว่า vertex ไหนบ้าง เรียงลำดับยังไง ประกอบเป็นสามเหลี่ยมแต่ละอัน
unsigned int indices[] = { 0, 1, 2 }; // triangle uses vertices 0, 1, 2
unsigned int ebo;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// later, draw through the index buffer instead of glDrawArrays:
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, 0);
EBO จะยังคง bind อยู่กับ VAO ตัวไหนก็ตามที่ถูก bind อยู่ตอนที่คุณเรียก glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ...) เหมือนกับที่ attribute layout ของ VBO เป็น นั่นคือประเด็นทั้งหมดของ VAO เลย มันรวม layout ของ VBO กับ EBO เข้าด้วยกัน ทำให้แค่ bind VAO ตัวเดียวตอน draw ก็เรียก setup ทั้งหมดกลับมาได้
Shader คือโปรแกรมเล็ก ๆ ที่รันตรงบน GPU เขียนด้วย GLSL (OpenGL Shading Language — ภาษาคล้าย C สำหรับโปรแกรม GPU) การวาดพื้นฐานต้องใช้ shader stage สองแบบ
gl_Position
const char* vertexSrc =
"#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main() {\n"
" gl_Position = vec4(aPos, 1.0);\n"
"}\n";
const char* fragmentSrc =
"#version 330 core\n"
"out vec4 FragColor;\n"
"void main() {\n"
" FragColor = vec4(1.0, 0.5, 0.2, 1.0);\n"
"}\n";
layout (location = 0) in vec3 aPos ใน vertex shader คืออีกฝั่งของ attribute slot 0 จาก Section 4 เลข 0 ตรงนั้นไม่ใช่เรื่องบังเอิญ มันคือวิธีที่ byte ของ vertex buffer กลายเป็นตัวแปร aPos ข้างใน GLSL ส่วน fragment shader ตัวนี้ไม่สนใจอะไรที่ vertex shader ส่งมาเลย แล้ว output สีส้มคงที่ตลอด vec4(1.0, 0.5, 0.2, 1.0) (red, green, blue, alpha)
GLSL source string ก็แค่ข้อความธรรมดา มันจะยังไม่ถูกคอมไพล์จนกว่าคุณจะส่งให้ driver ตอน runtime การคอมไพล์และเช็ก error ใช้รูปแบบเดียวกันทุก shader
unsigned int CompileShader(unsigned int type, const char* src)
{
unsigned int shader = glCreateShader(type);
glShaderSource(shader, 1, &src, nullptr);
glCompileShader(shader);
int success;
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
if (!success)
{
char log[512];
glGetShaderInfoLog(shader, 512, nullptr, log);
printf("Shader compile error: %s\n", log);
}
return shader;
}
ลองไล่ดูทีละขั้น: ถ้า vertexSrc มี typo เช่นลืมใส่ semicolon หลัง gl_Position = vec4(aPos, 1.0) glCompileShader จะ fail แบบเงียบ ๆ (มันไม่ throw หรือ crash) success จะกลับมาเป็น 0 และ console จะปริ้นท์อะไรประมาณ Shader compile error: 0:4(5): error: syntax error, unexpected '}' ตรงมาจาก GLSL compiler ของ driver เอง
พอ shader ทั้งสองคอมไพล์ผ่านแล้ว ให้ link มันเข้าเป็น shader program เดียว ซึ่งเป็นออบเจกต์ที่คุณ bind แล้ววาดด้วยจริง ๆ
unsigned int vs = CompileShader(GL_VERTEX_SHADER, vertexSrc);
unsigned int fs = CompileShader(GL_FRAGMENT_SHADER, fragmentSrc);
unsigned int program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program);
int linked;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (!linked)
{
char log[512];
glGetProgramInfoLog(program, 512, nullptr, log);
printf("Program link error: %s\n", log);
}
glDeleteShader(vs); // the compiled shader objects are copied into
glDeleteShader(fs); // the program at link time; the originals are no longer needed
GL_COMPILE_STATUS กับ GL_LINK_STATUS เสมอ แม้แต่ในโปรแกรมทดสอบเล่น ๆ shader ที่คอมไพล์ไม่ผ่านไม่ทำให้เกมคุณ crash มันแค่ทำให้ program ใช้ไม่ได้ และทุก draw call ถัดไปจะวาดอะไรไม่ออกแบบเงียบ ๆ (หรือวาดโดยใช้ program ที่ bind สำเร็จล่าสุดแทน) หน้าต่างดำหรือว่างเปล่าโดยไม่มี error ปริ้นท์ที่ไหนเลย เป็นบั๊ก OpenGL ยุคแรก ๆ ที่พบบ่อยที่สุด และแทบทุกครั้งมาจาก shader error ที่ไม่ได้เช็กTexture คือภาพที่เก็บในหน่วยความจำ GPU ที่ shader สามารถ sample ได้ (ดึงสีมาใช้ ปกติมี filtering ที่ผสมพิกเซลใกล้เคียงให้เนียน) การ setup ตามรูปแบบ generate-bind-configure-upload เหมือน buffer
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// width, height, and pixels already loaded from an image file (e.g. PNG)
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
glGenerateMipmap(GL_TEXTURE_2D);
parameter GL_TEXTURE_WRAP_S / GL_TEXTURE_WRAP_T ควบคุมว่าจะเกิดอะไรขึ้นเมื่อ texture coordinate เกินช่วง 0 ถึง 1 (GL_REPEAT คือ tile ซ้ำ) ส่วน filter parameter ควบคุมวิธีคำนวณพิกเซลระหว่าง texture sample glGenerateMipmap สร้างชุดสำเนาของ texture ที่เล็กลงเรื่อย ๆ และเบลอไว้ล่วงหน้า เรียกว่า chain mipmap เพื่อให้ texture ที่แสดงเล็กและไกล sample จากสำเนาที่ย่อไว้แล้วแทนที่จะเกิด aliasing และสั่นไหว
ข้างใน fragment shader คุณ sample texture ที่ bind อยู่ด้วย sampler2D และ function ในตัวชื่อ texture()
#version 330 core
in vec2 vUV;
out vec4 FragColor;
uniform sampler2D uTex;
void main()
{
FragColor = texture(uTex, vUV);
}
vUV ตรงนี้คือ texture coordinate ที่ส่งมาจาก vertex shader (ถูก interpolate อย่างเนียนทั่วสามเหลี่ยมโดย rasterizer สำหรับแต่ละ fragment) และ uTex คือ uniform (ค่าที่ set ครั้งเดียวจากโค้ด CPU แล้วคงที่ตลอดทุก vertex หรือ fragment ใน draw call เดียว ต่างจาก attribute ที่ต่างกันในแต่ละ vertex)
ทุก draw call เขียนสี (และโดยปกติ depth) ลงใน framebuffer โดย default คือหน้าต่างเอง เรียกว่า default framebuffer ที่ระบบจัดการให้ แล้ว swap ขึ้นจอครั้งละหนึ่งเฟรม แต่คุณสามารถสร้าง framebuffer object (FBO) ของตัวเองที่ render ลง texture แทนหน้าจอได้ ซึ่งเป็นพื้นฐานของ post-processing effect, shadow map, กระจก, และ render-to-texture UI
unsigned int fbo;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
unsigned int colorTex;
glGenTextures(1, &colorTex);
glBindTexture(GL_TEXTURE_2D, colorTex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTex, 0);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
{
printf("Framebuffer is not complete!\n");
}
glBindFramebuffer(GL_FRAMEBUFFER, 0); // back to the window
glTexImage2D ตรงนี้ส่ง nullptr เป็นข้อมูลพิกเซล texture ตัวนี้เริ่มต้นแบบว่างเปล่า มันคือปลายทางให้ render ลงไป ไม่ใช่ภาพที่อ่านมาจากไฟล์ glFramebufferTexture2D attach texture ว่างนั้นเป็น color output ของ framebuffer จากจุดนี้ อะไรก็ตามที่วาดตอน fbo ถูก bind อยู่จะไปลงที่ colorTex แทนที่จะขึ้นจอ pass ที่สองในภายหลังจะ bind default framebuffer (0) แล้ววาด full-screen quad ที่ sample colorTex เป็น texture ธรรมดา ใช้ post-processing effect อะไรก็ตามที่ fragment shader implement ไว้
glBindFramebuffer(GL_FRAMEBUFFER, 0) หลังทำ off-screen pass เสร็จ ทุก draw call หลังจากนั้นจะไปลงใน off-screen texture ต่อแทนที่จะลงหน้าต่าง หน้าจอเลยดูเหมือนหยุดอัปเดต (มันค้างอยู่ที่สิ่งสุดท้ายที่วาดลง default framebuffer) ในขณะที่ rendering ที่เหลือดูเหมือนทำงานปกติดีตอนนี้ทุกชิ้นส่วนพร้อมแล้ว Draw call คือ function call ที่บอก GPU จริง ๆ ว่า "รัน program ที่ bind อยู่ตอนนี้กับข้อมูล vertex ที่ bind อยู่ตอนนี้ เดี๋ยวนี้เลย" นี่คือ setup เต็มและ render loop ที่รวม Section 4 กับ 5 เข้าด้วยกัน (การสร้างหน้าต่างและ OpenGL context ผ่านไลบรารีอย่าง GLFW ถูกตัดออกไป เพราะเป็นเรื่อง windowing boilerplate ไม่ใช่ OpenGL เอง)
// Assume `window` already exists and a valid OpenGL context is current.
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
unsigned int vao, vbo;
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
unsigned int program = BuildProgram(vertexSrc, fragmentSrc); // Section 5's two functions combined
while (!glfwWindowShouldClose(window))
{
glClearColor(0.1f, 0.1f, 0.12f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(program);
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, 3); // 3 vertices, starting at index 0
glfwSwapBuffers(window);
glfwPollEvents();
}
ผลลัพธ์ที่คาดหวัง: หน้าต่างเปิดขึ้นมาพื้นหลังสีเทาอมฟ้าเข้ม แล้วสามเหลี่ยมสีส้มทึบปรากฏตรงกลาง หันขึ้นด้านบน มุมทั้งสามตรงกับคู่ (x, y) ทั้งสามใน vertices เป๊ะ ๆ ที่ map จากช่วง -1 ถึง 1 ของ OpenGL ไปเป็นพิกเซลของหน้าต่าง
สังเกตว่า glDrawArrays(GL_TRIANGLES, 0, 3) เพียงอย่างเดียวไม่ได้บอกว่า จะอ่าน buffer ไหน (มาจาก VAO ที่ bind อยู่), จะรัน shader ตัวไหน (มาจาก program ที่ bind อยู่), จะเขียนพิกเซลไปที่ไหน (มาจาก framebuffer ที่ bind อยู่) draw call ของ OpenGL สั้นได้ก็เพราะมันแอบใช้สิ่งที่ bind อยู่ตอนนี้ซ้ำแบบเงียบ ๆ ซึ่งเขียนสะดวกดี และนั่นคือ hidden state เป๊ะ ๆ ที่ Section 2 พูดถึงว่าเป็นข้อแลกเปลี่ยนของ OpenGL
ความสะดวกของ OpenGL ไม่ได้ฟรี ทุก call gl* ถูกเช็กและ validate โดย driver, state ถูก track และ re-verify เกือบทุก call และที่สำคัญคือ OpenGL context ทั้งหมด effectively เป็น single-threaded call จาก CPU thread อื่นแทบทั้งหมดต้องไหลผ่าน internal lock ใน driver หลายปีที่ผ่านมามันโอเค เพราะ GPU คือคอขวด ไม่ใช่ CPU ที่ยิง draw call
แต่นั่นเปลี่ยนไป เกมยุคใหม่ยิง draw call หลายพันครั้งต่อเฟรม และ CPU ยุคใหม่มีหลายคอร์ที่ว่างเปล่าอยู่ ในขณะที่ OpenGL บังคับให้ API traffic เกือบทั้งหมดไหลผ่าน thread เดียว วัดจากเกมจริง สัดส่วนที่มีนัยสำคัญของ CPU time ในหนึ่งเฟรมอาจหมดไปกับแค่ driver ที่ re-validate state และแปล call ที่มันเคยเห็นมาแล้วในเฟรมก่อนหน้า
Vulkan (2016) และ DirectX 12 (2015) พร้อมกับ Metal ของ Apple ถูกออกแบบมารอบชุด fix เดียวกัน
ค่าใช้จ่ายคือความยืดยาวและความรับผิดชอบ "hello triangle" แบบขั้นต่ำที่ประมาณ 80 บรรทัดใน OpenGL มักจะกลายเป็น 800 ถึง 1,000+ บรรทัดใน Vulkan เพราะงานที่ driver ของ OpenGL เคยทำให้แบบเงียบ ๆ เช่นเลือก GPU, จัดการ memory, ติดตามว่าเมื่อไหร่ปลอดภัยที่จะใช้ buffer ซ้ำ ตอนนี้กลายเป็นหน้าที่ของคุณ explicit ทั้งหมด นี่คือเหตุผลเป๊ะ ๆ ว่าทำไมสตูดิโอที่สร้างหรือดูแลเอนจิน in-house จริงจังถึงรับสมัครคนที่มีประสบการณ์ Vulkan/DirectX 12 โดยเฉพาะ การทำสิ่งนี้ให้ถูกต้อง และทำได้เร็ว เป็นทักษะเฉพาะทางจริง ๆ
Vulkan แทนที่ implicit context ตัวเดียวของ OpenGL ด้วย hierarchy ของออบเจกต์ explicit ที่คุณสร้างเองตามลำดับ
ไล่ hierarchy นั้นในโค้ด (ตัด field ของ struct ออกเพื่อความสั้น เวอร์ชันจริงจะใส่ application info, extension ที่ต้องใช้ และ validation layer เข้าไปด้วย)
VkInstanceCreateInfo instanceInfo{};
instanceInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
VkInstance instance;
vkCreateInstance(&instanceInfo, nullptr, &instance);
uint32_t gpuCount = 0;
vkEnumeratePhysicalDevices(instance, &gpuCount, nullptr);
std::vector<VkPhysicalDevice> gpus(gpuCount);
vkEnumeratePhysicalDevices(instance, &gpuCount, gpus.data());
VkPhysicalDevice physicalDevice = gpus[0]; // real code scores and picks the best one
VkDeviceCreateInfo deviceInfo{};
deviceInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
VkDevice device;
vkCreateDevice(physicalDevice, &deviceInfo, nullptr, &device);
VkQueue graphicsQueue;
vkGetDeviceQueue(device, graphicsQueueFamilyIndex, 0, &graphicsQueue);
แต่ละชิ้นมีหน้าที่หนึ่งอย่าง
vkEnumeratePhysicalDevices ถูกเรียกสองครั้งโดยตั้งใจ เป็นแพทเทิร์นที่ใช้ทั่ว Vulkan คือเรียกครั้งแรกโดยส่ง output pointer เป็น null เพื่อถามว่า "มีกี่ตัว" แล้วเรียกครั้งที่สองด้วย array ขนาดพอดีเพื่อรับค่าจริง ๆใน OpenGL การเรียก function อย่าง glDrawArrays effectively รันทันที (หรือใกล้เคียง) Vulkan กับ DirectX 12 แยกเรื่องนี้เป็นสองขั้น คือคุณ record ลำดับ command เข้าไปใน command buffer ก่อน ยังไม่มีอะไรเกิดขึ้นบน GPU เลย คุณแค่กำลังสร้าง list จากนั้นคุณ submit buffer ทั้งก้อนไปที่ queue แล้ว GPU จะรันมันเมื่อไหร่ก็ได้ที่มันว่าง อาจ overlap กับงานอื่นที่ submit ไว้ด้วย
vkBeginCommandBuffer(cmd, &beginInfo);
VkRenderPassBeginInfo rpInfo{};
rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
rpInfo.renderPass = renderPass;
rpInfo.framebuffer = swapchainFramebuffers[imageIndex];
rpInfo.renderArea.extent = swapchainExtent;
rpInfo.clearValueCount = 1;
rpInfo.pClearValues = &clearColor;
vkCmdBeginRenderPass(cmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE);
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, trianglePipeline);
vkCmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer, &offset);
vkCmdDraw(cmd, 3, 1, 0, 0); // 3 vertices, 1 instance, starting at vertex 0, instance 0
vkCmdEndRenderPass(cmd);
vkEndCommandBuffer(cmd);
ทุก function vkCmd* ตรงนี้แค่เติมคำสั่งเข้าไปใน list ที่ cmd เก็บไว้ ไม่มีตัวไหนแตะ GPU โดยตรงเลย การ submit เป็นขั้นแยกต่างหากแบบ explicit
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = &imageAvailableSemaphore;
submitInfo.pWaitDstStageMask = &waitStage;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &cmd;
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &renderFinishedSemaphore;
vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFence);
field waitSemaphoreCount/signalSemaphoreCount ตอน submit คือ synchronization เป๊ะ ๆ ที่ Section 9 บอกไว้ว่าคุณต้องเขียนเองนี่แหละ เรื่องนี้คือหัวข้อของ section ถัดไป
งาน GPU เป็น asynchronous (มันรันแยกอิสระจาก และอาจช้ากว่ามากจากโค้ด CPU ที่ submit มัน) CPU สามารถ submit command buffer แล้วไปทำงาน record เฟรมถัดไปต่อได้ทันที ในขณะที่ GPU ยังทำเฟรมก่อนหน้าอยู่ นั่นคือความเร็วเป๊ะ ๆ ที่ Vulkan ถูกสร้างมาเพื่อ แต่มันแปลว่าไม่มีอะไรหยุดคุณจากการ เช่น เขียนทับ buffer ที่ GPU ยังอ่านไม่เสร็จ หรือ sample texture ก่อนที่ pass ก่อนหน้าจะเขียนลงมันเสร็จ ใน OpenGL driver แอบใส่ wait ให้เพื่อป้องกันเรื่องนี้ ใน Vulkan กับ DirectX 12 ถ้าคุณไม่บอกเอง มันไม่เกิดขึ้น คุณจะได้ data race มา หน้าจอกระพริบ, พิกเซลขยะ, หรือ error จาก validation layer ตอน develop
เครื่องมือสามอย่างจัดการการรอสามแบบต่างกัน
VkImageMemoryBarrier barrier{};
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
barrier.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
barrier.srcAccessMask = 0;
barrier.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
barrier.image = colorImage;
barrier.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };
vkCmdPipelineBarrier(
cmd,
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, // wait for nothing before this
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, // block this stage until satisfied
0,
0, nullptr,
0, nullptr,
1, &barrier);
DirectX 12 ถูกสร้างขึ้นรอบปรัชญา explicit เดียวกับ Vulkan ออกมาช่วงเวลาใกล้เคียงกัน ด้วยเหตุผลเดียวกับที่อธิบายใน Section 9 ถ้าคุณเข้าใจโมเดลของ Vulkan จาก Section 10 ถึง 12 แล้ว DirectX 12 ก็แค่ปัญหาเรื่องคำศัพท์เป็นส่วนใหญ่
แนวคิดหนึ่งที่ควรใช้เวลาทำความเข้าใจให้ดีคือ root signature ใน Vulkan การบอก shader ว่ามันเข้าถึง resource อะไรได้ถูกแยกไว้ระหว่าง pipeline layout กับ descriptor set layout หนึ่งตัวหรือมากกว่า DirectX 12 รวมสิ่งนี้เข้าเป็น root signature ตัวเดียว (ID3D12RootSignature) ลองคิดว่ามันเป็นเหมือน function signature สำหรับ pipeline ทั้งตัว มันประกาศว่า "shader ของ pipeline นี้คาดหวัง constant buffer ที่ register b0, texture กับ sampler ที่ register t0/s0" เป็นต้น ตอน draw คุณ bind descriptor table จริง ๆ (กลุ่มของ resource reference ที่อยู่ใน descriptor heap, ID3D12DescriptorHeap) ที่ตรงกับรูปร่างที่ประกาศไว้นั้น
ID3D12CommandQueue* queue;
D3D12_COMMAND_QUEUE_DESC queueDesc = {};
queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
device->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&queue));
commandList->SetGraphicsRootSignature(rootSignature);
commandList->SetPipelineState(pipelineState); // the PSO: shaders + blend/depth/raster state, baked
commandList->IASetVertexBuffers(0, 1, &vertexBufferView);
commandList->DrawInstanced(3, 1, 0, 0); // 3 vertices, 1 instance
commandList->Close();
ID3D12CommandList* lists[] = { commandList };
queue->ExecuteCommandLists(1, lists);
สังเกตว่ารูปร่างเหมือนกับ Vulkan เป๊ะ คือสร้าง pipeline object ครั้งเดียว (คือ PSO), record command ลงใน list, ปิด list แล้วส่งให้ queue รัน วงการมาบรรจบกันที่แพทเทิร์น explicit เดียวกันนี้ใน Vulkan, DirectX 12 และ Metal ในช่วงเวลาใกล้เคียงกัน เพราะทุกตัวกำลังแก้ปัญหา CPU overhead เดียวกัน
unsigned int vao, vbo;
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glBindVertexArray(0);
// ... later, in the render loop ...
glUseProgram(program);
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
Call ที่หายไปคือ glEnableVertexAttribArray(0) glVertexAttribPointer แค่อธิบายว่า attribute slot 0 จะ ถูกอ่านจาก buffer ยังไง มันไม่ได้เปิด slot นั้นให้ใช้งาน attribute slot ทุกตัวเริ่มต้นแบบปิดอยู่ พอ slot 0 ถูกปิด input aPos ของ vertex shader จะอ่านค่า default คงที่ (0, 0, 0, 1) สำหรับทุก ๆ vertex ไม่ว่าอะไรจะอยู่ใน VBO จริง ๆ ก็ตาม vertex ทั้งสามของสามเหลี่ยมจะซ้อนทับกันที่จุดเดียวกันตรง origin เป๊ะ ๆ สามเหลี่ยมมีพื้นที่เป็นศูนย์ และ rasterizer จะไม่สร้าง fragment ให้เลย ดังนั้น fragment shader จึงไม่เคยรัน และไม่มีอะไรถูกวาด หน้าจอยัง clear ได้ปกติเพราะ glClear ไม่ได้ขึ้นอยู่กับสามเหลี่ยมที่พังนั้นเลย
unsigned int vao, vbo;
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0); // added: the fix
glBindVertexArray(0);
glDrawElements มุมทั้งสี่คือ (-0.5, -0.5, 0), (0.5, -0.5, 0), (0.5, 0.5, 0), (-0.5, 0.5, 0) สองสามเหลี่ยมควรเป็น (0, 1, 2) และ (2, 3, 0)
float vertices[] = {
-0.5f, -0.5f, 0.0f, // 0: bottom-left
0.5f, -0.5f, 0.0f, // 1: bottom-right
0.5f, 0.5f, 0.0f, // 2: top-right
-0.5f, 0.5f, 0.0f // 3: top-left
};
unsigned int indices[] = {
0, 1, 2, // first triangle
2, 3, 0 // second triangle
};
unsigned int vao, vbo, ebo;
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);
glGenBuffers(1, &ebo);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glBindVertexArray(0);
// in the render loop:
glUseProgram(program);
glBindVertexArray(vao);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0); // 6 indices total
เก็บ vertex แค่ 4 ตัว ทั้งที่ 2 สามเหลี่ยมต้องการมุมรวมกัน 6 มุม เพราะ vertex 0 กับ 2 ถูกใช้ซ้ำโดยทั้งสองสามเหลี่ยมผ่าน index buffer แทนที่จะถูกเขียนซ้ำสองครั้งใน vertices[] EBO ถูก bind ในขณะที่ vao ถูก bind อยู่ ดังนั้น VAO จะจำมันไว้ด้วย แค่ glBindVertexArray(vao) ตัวเดียวตอน draw ก็เรียก vertex layout, VBO, และ EBO กลับมาพร้อมกันทั้งหมด argument ตัวที่สองของ glDrawElements คือ 6 หมายถึงจำนวน index ที่จะอ่าน ไม่ใช่จำนวน vertex ที่ไม่ซ้ำกัน
(a) การ Acquire swapchain image: semaphore ตัวหนึ่ง (มักตั้งชื่อ imageAvailableSemaphore) ถูก signal โดย vkAcquireNextImageKHR มีแค่ Pass B เท่านั้นที่ต้องรอบนมัน เพราะ Pass B คือตัวที่เขียนลง swapchain image ส่วน Pass A แตะแค่ off-screen texture ของตัวเองเท่านั้น ไม่ต้องการ swapchain image เลย ดังนั้นในทางทฤษฎีมันเริ่ม record หรือแม้แต่รันได้ตั้งแต่ก่อน swapchain image พร้อมด้วยซ้ำ
(b) ระหว่าง Pass A กับ Pass B: pipeline (image memory) barrier บน off-screen color texture เปลี่ยนจาก COLOR_ATTACHMENT_OPTIMAL (layout ที่ใช้ตอน Pass A กำลังเขียนลงไป) เป็น SHADER_READ_ONLY_OPTIMAL (layout ที่ fragment shader ของ Pass B ต้องการเพื่อ sample มัน) พร้อม memory dependency ที่รับประกันว่างานเขียนสีของ Pass A เสร็จและมองเห็นได้แล้วก่อนที่ shader ของ Pass B จะอ่าน ถ้าทั้งสอง pass ถูก record ลงใน command buffer เดียวกัน นี่คือ vkCmdPipelineBarrier call เดียวคั่นกลาง ถ้าอยู่คนละ command buffer แล้ว submit แยกกัน การรับประกันแบบเดียวกันต้องมาจากวิธีที่ submission เหล่านั้นถูกเรียงลำดับและ synchronize
(c) Pass B เสร็จแล้ว Present: semaphore ตัวหนึ่ง (มักตั้งชื่อ renderFinishedSemaphore) ถูก signal เมื่อ command buffer ของ Pass B รันเสร็จบน GPU ซึ่ง vkQueuePresentKHR รอบนมันก่อนที่ image จะถูกแสดงจริง ๆ แยกต่างหาก ต้องมี fence สำหรับเฟรมนั้น (มักตั้งชื่อ inFlightFence) เพื่อให้ CPU รู้ว่าเมื่อไหร่ปลอดภัยที่จะใช้ command buffer และ resource ของเฟรมนี้ซ้ำ ปกติจะเช็กก่อนที่ CPU จะเริ่ม record slot เฟรม-in-flight เดียวกันอีกครั้ง สองหรือสามเฟรมถัดมา