7.7 Graphics API (OpenGL, Vulkan, DirectX 12)

เฟส 7 · กราฟิกและ Rendering · เวลาเรียน: 120–200 h

API ระดับล่างที่อยู่ใต้เอนจิน สำหรับสายกราฟิกและเอนจินลึก: buffer, shader, pipeline, command buffer และ synchronization

ทุกอย่างที่คุณเคยเห็นวาดขึ้นมาบนหน้าจอในเกม เกิดขึ้นเพราะโปรแกรมบน CPU เรียก graphics API ซ้ำ ๆ หลายครั้งต่อหนึ่งเฟรม เพื่อบอก GPU ว่าต้องวาดอะไรและวาดยังไงแบบเป๊ะ ๆ ใน Unity คุณไม่เคยเห็น call พวกนี้เลย เพราะเอนจินเรียกให้คุณ บทนี้จะเปิดฝาให้ดูข้างใน คุณจะเริ่มเรียนรู้ OpenGL ซึ่งเป็น API กราฟิกที่ง่ายที่สุดในสามตัวจริง ๆ ที่ใช้กันในอุตสาหกรรม โดยการสร้างสามเหลี่ยมที่ทำงานได้จริงจากชิ้นส่วนดิบ ๆ ได้แก่ buffer ของข้อมูล vertex, คำอธิบาย layout ของข้อมูลนั้น, โปรแกรมเล็ก ๆ ที่คอมไพล์แล้วสองตัวเรียกว่า shader, และ function call หนึ่งตัวที่สุดท้ายจะวาดอะไรบางอย่างออกมาจริง ๆ จากนั้นคุณจะเห็นว่าทำไมวงการถึงย้ายออกจาก OpenGL ไปใช้ Vulkan กับ DirectX 12 เป็นส่วนใหญ่ และเรียนรู้รูปร่างของ API สองตัวนั้น ซึ่งเป็นรูปร่างที่โผล่มาในบทสัมภาษณ์งานเอนจินจริง ๆ

1. Graphics 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 คือตัวที่ซ่อนความต่างของฮาร์ดแวร์เอาไว้

Your game code (C++, C#, ...) | v Graphics API calls (glDrawArrays, vkQueueSubmit, ...->DrawInstanced) | v GPU driver (vendor-specific software: translates API calls into real instructions for THIS GPU) | v GPU hardware (thousands of small cores running in parallel)

มี graphics API สามตัวที่สำคัญต่อสายอาชีพ game programmer ในวันนี้

บทนี้จะสอนออบเจกต์หลัก ๆ โดยใช้ OpenGL ก่อน เพราะ function call ของมัน map ตรงเข้ากับแนวคิดพื้นฐาน (buffer, shader, texture, draw call) โดยไม่มีพิธีรีตองเพิ่มเติม พอแนวคิดพวกนี้แน่นแล้ว Vulkan กับ DirectX 12 จะไม่ดูเป็นภาษาต่างดาวอีกต่อไป มันคือแนวคิดเดียวกัน แค่ทำแบบ explicit แทนที่จะทำอัตโนมัติ

2. สาม API งานเดียวกัน

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 จะอธิบายว่าทำไมการแลกเปลี่ยนนี้ถึงคุ้มค่า

Tip ไม่มี API ไหนใน 3 ตัวนี้ที่ "ดีกว่า" แบบเบ็ดเสร็จ มันคือเครื่องมือสำหรับงานต่างกัน เครื่องมือเล็ก ๆ, editor, และโปรเจกต์เรียนรู้ยังใช้ OpenGL เพราะทำให้อะไรขึ้นจอได้เร็ว ส่วนเอนจิน AAA (Unreal, เอนจิน in-house ของสตูดิโอส่วนใหญ่) ใช้ Vulkan และ/หรือ DirectX 12 เพราะต้องการประสิทธิภาพ CPU กับ GPU สูงสุดเท่าที่จะทำได้ ในเกม draw call หลายพันครั้งต่อเฟรม

3. Object Model ของ OpenGL: กราฟที่คุณ Bind เข้าไป

ก่อนเขียนโค้ด ควรเห็นภาพรวมทั้งหมดก่อน งานของ OpenGL ถูกสร้างจากออบเจกต์ไม่กี่ประเภท และคุณเชื่อมมันเข้าด้วยกันด้วยการ bind (ทำให้ออบเจกต์เป็น "current" เพื่อให้ function call ถัดไปทำงานกับมันโดยปริยาย) พอคุณเห็นกราฟนี้ในหัวได้ ทุก OpenGL function call จะกลายเป็นแค่ "กำลัง bind ออบเจกต์ไหนอยู่ และกำลังสั่งอะไรมัน"

VAO (bound) Program (bound) | | +-- remembers: "attribute 0 comes +-- vertex shader (compiled) | from THIS buffer, read it +-- fragment shader (compiled) | as 3 floats, stride 12 bytes" +-- linked together into one program | +--> VBO (raw vertex bytes: positions, colors, UVs, ...) +--> EBO (optional: which vertices form each triangle) Texture unit 0 --> Texture object --> sampled inside the fragment shader Framebuffer (bound) --> color attachment (texture or renderbuffer) --> depth attachment (optional)

ออบเจกต์ห้าแบบ ห้าหน้าที่

ห้า section ถัดไปจะสร้างแต่ละอย่างในโค้ด ทีละตัว ก่อนที่ Section 8 จะเอาทั้งหมดมาต่อกันเป็นโปรแกรมที่ทำงานได้จริงตัวเดียว

4. Vertex Buffer (VBO) และ Vertex Array Object (VAO)

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

vertices[] in your CPU array: [x0 y0 z0][x1 y1 z1][x2 y2 z2] | glBufferData copies the bytes v GPU buffer memory (the VBO): [x0 y0 z0][x1 y1 z1][x2 y2 z2] ^ glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 12, 0) means: "attribute 0 = 3 floats, a new one starts every 12 bytes, the first one starts at byte 0"
Common mistake เรียก 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 ทั้งหมดกลับมาได้

5. Shader: จาก GLSL Source สู่ Program ที่ Link แล้ว

Shader คือโปรแกรมเล็ก ๆ ที่รันตรงบน GPU เขียนด้วย GLSL (OpenGL Shading Language — ภาษาคล้าย C สำหรับโปรแกรม GPU) การวาดพื้นฐานต้องใช้ shader stage สองแบบ


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
Tip ต้องเช็ก GL_COMPILE_STATUS กับ GL_LINK_STATUS เสมอ แม้แต่ในโปรแกรมทดสอบเล่น ๆ shader ที่คอมไพล์ไม่ผ่านไม่ทำให้เกมคุณ crash มันแค่ทำให้ program ใช้ไม่ได้ และทุก draw call ถัดไปจะวาดอะไรไม่ออกแบบเงียบ ๆ (หรือวาดโดยใช้ program ที่ bind สำเร็จล่าสุดแทน) หน้าต่างดำหรือว่างเปล่าโดยไม่มี error ปริ้นท์ที่ไหนเลย เป็นบั๊ก OpenGL ยุคแรก ๆ ที่พบบ่อยที่สุด และแทบทุกครั้งมาจาก shader error ที่ไม่ได้เช็ก

6. Texture: เอาภาพขึ้นไปบน GPU

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)

7. Framebuffer: Rendering ไปลงที่ไหนจริง ๆ

ทุก 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 ไว้

Common mistake ลืมเรียก glBindFramebuffer(GL_FRAMEBUFFER, 0) หลังทำ off-screen pass เสร็จ ทุก draw call หลังจากนั้นจะไปลงใน off-screen texture ต่อแทนที่จะลงหน้าต่าง หน้าจอเลยดูเหมือนหยุดอัปเดต (มันค้างอยู่ที่สิ่งสุดท้ายที่วาดลง default framebuffer) ในขณะที่ rendering ที่เหลือดูเหมือนทำงานปกติดี

8. Draw Call: โปรแกรม "วาดสามเหลี่ยม" แบบเต็ม

ตอนนี้ทุกชิ้นส่วนพร้อมแล้ว 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 ไปเป็นพิกเซลของหน้าต่าง

CPU: glDrawArrays(GL_TRIANGLES, 0, 3) | v GPU pulls 3 vertices through the bound VAO's remembered layout | v Vertex shader runs once per vertex --> outputs gl_Position | v Rasterizer turns the triangle into fragments (candidate pixels) | v Fragment shader runs once per fragment --> outputs a color | v Color written into the bound framebuffer (the window, by default)

สังเกตว่า glDrawArrays(GL_TRIANGLES, 0, 3) เพียงอย่างเดียวไม่ได้บอกว่า จะอ่าน buffer ไหน (มาจาก VAO ที่ bind อยู่), จะรัน shader ตัวไหน (มาจาก program ที่ bind อยู่), จะเขียนพิกเซลไปที่ไหน (มาจาก framebuffer ที่ bind อยู่) draw call ของ OpenGL สั้นได้ก็เพราะมันแอบใช้สิ่งที่ bind อยู่ตอนนี้ซ้ำแบบเงียบ ๆ ซึ่งเขียนสะดวกดี และนั่นคือ hidden state เป๊ะ ๆ ที่ Section 2 พูดถึงว่าเป็นข้อแลกเปลี่ยนของ OpenGL

9. ทำไม Vulkan กับ DirectX 12 ถึงเกิดขึ้น

ความสะดวกของ 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 ที่มันเคยเห็นมาแล้วในเฟรมก่อนหน้า

OpenGL (implicit, effectively single-threaded): Main thread only: set state --> glDrawArrays --> set state --> glDrawArrays --> ... (thousands of calls, one after another, one thread; the driver re-validates a lot of state on nearly every single call) Vulkan / DirectX 12 (explicit, multi-threaded recording): Thread 1: record command buffer A (objects 1 - 250) Thread 2: record command buffer B (objects 251 - 500) Thread 3: record command buffer C (objects 501 - 750) Thread 4: record command buffer D (objects 751 - 1000) | | | | +----------+----+-----+----------+ v one queue submit call, all four command buffers at once

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 โดยเฉพาะ การทำสิ่งนี้ให้ถูกต้อง และทำได้เร็ว เป็นทักษะเฉพาะทางจริง ๆ

10. โมเดลของ Vulkan: Device, Queue, Swapchain, Pipeline

Vulkan แทนที่ implicit context ตัวเดียวของ OpenGL ด้วย hierarchy ของออบเจกต์ explicit ที่คุณสร้างเองตามลำดับ

VkInstance | v VkPhysicalDevice (one entry per real GPU in the machine -- | you enumerate and PICK one, you do not create it) v VkDevice (your "logical" handle to that GPU, created with | the specific features/extensions/queues you asked for) | +--> VkQueue (graphics) +--> VkSwapchainKHR --> VkImage, VkImage, ... +--> VkQueue (compute) +--> VkCommandPool --> VkCommandBuffer, ... +--> VkQueue (transfer) +--> VkPipeline (shaders + ALL fixed-function state, baked together at creation time) +--> VkDescriptorPool --> VkDescriptorSet (how shaders find their textures/buffers)

ไล่ 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);

แต่ละชิ้นมีหน้าที่หนึ่งอย่าง

Tip Pipeline ใน Vulkan/DirectX 12 หนักกว่า "program" ของ OpenGL มาก การสร้างหนึ่งตัวอาจกินเวลาเป็นมิลลิวินาที เพราะ driver คอมไพล์และ validate ทุกอย่างเกี่ยวกับมันล่วงหน้า เอนจินจริงมักสร้าง pipeline ตอน loading screen หรือใน background thread แล้ว cache ลงดิสก์ เพื่อหลีกเลี่ยงอาการกระตุกตอนที่ pipeline ใหม่ถูกต้องการกลางเกม นี่คือที่มาของ "shader compilation stutter" ที่มีชื่อเสียงในเกม PC บางเกม

11. Command Buffer และ Submit Flow

ใน 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);
Thread A --> records VkCommandBuffer cmd (all vkCmd* calls above) | v vkQueueSubmit(graphicsQueue, cmd, waits on: imageAvailableSemaphore, signals: renderFinishedSemaphore, signals: inFlightFence) | v GPU executes cmd's recorded commands, in order | v renderFinishedSemaphore signaled --> vkQueuePresentKHR waits on it | v Swapchain image shown on screen

field waitSemaphoreCount/signalSemaphoreCount ตอน submit คือ synchronization เป๊ะ ๆ ที่ Section 9 บอกไว้ว่าคุณต้องเขียนเองนี่แหละ เรื่องนี้คือหัวข้อของ section ถัดไป

12. Synchronization: ส่วนที่ยากที่สุด

งาน 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);
Time --> Command buffer 1 (compute pass): [ write texture X ] | pipeline barrier: compute writes to X must be visible before the fragment stage reads X; X's layout changes GENERAL --> SHADER_READ_ONLY | Command buffer 2 (graphics pass): [ fragment shader reads X ] Fence -- lets the CPU know when a whole GPU submission is done Semaphore -- lets one GPU submission wait on another GPU submission Barrier -- lets one GPU stage/resource wait on an earlier GPU stage/resource, and/or changes an image's layout
Common mistake ใช้ fence กับ semaphore สลับกันได้เหมือนเป็นของแทนกันได้ หรือข้าม barrier ที่เปลี่ยน layout ก่อน sample texture ที่เพิ่ง render ลงไป validation layer ของ Vulkan (layer สำหรับ debug ที่คุณเปิดใช้ตอน develop ซึ่งเช็ก usage ของคุณเทียบกับ spec) จะจับบั๊กพวกนี้ได้ส่วนใหญ่และปริ้นท์ error ที่เจาะจงอ่านง่าย ควรเปิด validation layer ไว้เสมอตอน develop เพราะถ้าไม่มีมัน บั๊กแบบเดียวกันอาจโผล่มาเป็นความเสียหายทางภาพแบบเงียบ ๆ ที่ตามหาได้ยากกว่ามาก

13. DirectX 12: แนวคิดเดียวกัน แค่เปลี่ยนชื่อ

DirectX 12 ถูกสร้างขึ้นรอบปรัชญา explicit เดียวกับ Vulkan ออกมาช่วงเวลาใกล้เคียงกัน ด้วยเหตุผลเดียวกับที่อธิบายใน Section 9 ถ้าคุณเข้าใจโมเดลของ Vulkan จาก Section 10 ถึง 12 แล้ว DirectX 12 ก็แค่ปัญหาเรื่องคำศัพท์เป็นส่วนใหญ่

Vulkan DirectX 12 ----------------------------- ----------------------------- VkPhysicalDevice IDXGIAdapter VkDevice ID3D12Device VkQueue ID3D12CommandQueue VkCommandBuffer ID3D12GraphicsCommandList VkCommandPool ID3D12CommandAllocator VkPipeline ID3D12PipelineState ("the PSO") VkPipelineLayout + VkDescriptorSetLayout ID3D12RootSignature VkDescriptorPool / VkDescriptorSet ID3D12DescriptorHeap VkFence ID3D12Fence VkSemaphore folded into ID3D12Fence usage in DX12

แนวคิดหนึ่งที่ควรใช้เวลาทำความเข้าใจให้ดีคือ 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 เดียวกัน

Tip สำหรับมือใหม่ที่วางแผนสายอาชีพ ให้เรียน OpenGL ก่อนเพื่อจับแนวคิดหลัก (buffer, shader, texture, draw call) โดยไม่มีพิธีรีตองเพิ่ม จากนั้นค่อยหยิบ Vulkan ถ้าอยากทำงานเอนจินข้ามแพลตฟอร์ม (เอนจิน in-house ส่วนใหญ่, Android, Linux และมันยังครอบคลุมการสัมภาษณ์ที่สตูดิโอ Windows-only ด้วย เพราะแนวคิดถ่ายทอดตรงกันได้) เรียน DirectX 12 เฉพาะถ้าคุณเล็งสตูดิโอที่ ship บน Windows/Xbox เป็นหลัก คำศัพท์ในตารางของ Section 13 คือช่องว่างหลักที่ต้องปิดหลังจากที่คุณรู้ Vulkan อยู่แล้ว

14. คำศัพท์

15. แบบฝึกหัด

Exercise 1 — หา Call ที่หายไป โค้ดข้างล่างนี้คอมไพล์และรันได้โดยไม่มี error ปริ้นท์ที่ไหนเลย หน้าต่างเปิดขึ้นมาและ clear เป็นสีพื้นหลัง แต่ไม่มีสามเหลี่ยมปรากฏขึ้นเลย ใช้ object graph จาก Section 3 และกล่องคำเตือนใน Section 4 หา call ที่หายไป แล้วอธิบายด้วยคำพูดของคุณเองว่าทำไม GPU ถึงไม่วาดอะไรออกมาเลย

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);
Show answer

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);
Exercise 2 — วาด Quad ด้วย Index Buffer ใช้โค้ด VBO/VAO ของ Section 4 และตัวอย่าง EBO เป็นแนวทาง เขียน setup สำหรับสี่เหลี่ยมจัตุรัสที่ทำจากสองสามเหลี่ยม แชร์มุมทั้งสี่เป็น vertex ที่ไม่ซ้ำกันสี่ตัว (ไม่ duplicate) โดยใช้ index buffer และ 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)
Show answer

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 ที่ไม่ซ้ำกัน

Exercise 3 — วางแผน Synchronization Vulkan renderer ตัวหนึ่งทำสอง pass ในแต่ละเฟรม Pass A render ฉากลงไปใน off-screen color texture Pass B sample texture นั้นใน full-screen fragment shader เพื่อ composite bloom effect ลงบน swapchain image แล้วเฟรมนั้นถูก present ให้ list เรียงลำดับทุก fence, semaphore หรือ barrier ที่คุณต้องใช้ระหว่าง (a) การ acquire swapchain image, (b) Pass A เสร็จแล้ว Pass B เริ่ม, และ (c) Pass B เสร็จแล้ว present แต่ละอันให้บอกสั้น ๆ ว่าทำไมถึงต้องใช้
Show answer

(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 เดียวกันอีกครั้ง สองหรือสามเฟรมถัดมา

← กลับไปหน้ารวมบท