iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0
Software Development

渲染與GPU編程系列 第 14

Day 13|Vulkan 建立 Shader Module 並繪製三角形

  • 分享至 

  • xImage
  •  

目標很單純:把你昨天(Day 12)的骨架,從「清畫面」升級到「真的畫出第一個三角形」
我們會做 4 件事:(1) 準備 GLSL → SPIR-V、(2) 建 Shader Module、(3) 建 Graphics Pipeline、(4) 在 Render Pass 裡下指令 vkCmdDraw
特別註明:本篇不使用 Dynamic Rendering,完全走傳統 Render Pass + Framebuffer 流程。


0)前置條件:沿用 Day 11/12 的骨架

你已經有這些成員與物件(名稱可不同):

  • VkDevice deviceVkPhysicalDevice physVkQueue graphicsQ/presentQ
  • VkRenderPass renderPass(單一 color 附件;finalLayout=VK_IMAGE_LAYOUT_PRESENT_SRC_KHR
  • std::vector<VkImageView> swapViewsstd::vector<VkFramebuffer> framebuffersVkExtent2D swapExtent
  • VkCommandPool cmdPoolstd::vector<VkCommandBuffer> cmdBufs
  • 同步物件:VkSemaphore semImage/semRenderVkFence inFlight
  • 你的 drawFrame() 主迴圈(Acquire → Record → Submit → Present)

若你昨天照做,這些都齊全。沒有的話先回去把「清畫面」跑通。


1)寫兩支最小 Shader(GLSL),並編成 SPIR-V

一開始先用最簡單版本:不用頂點緩衝,直接靠 gl_VertexIndex 在 VS 裡產生三個頂點。顏色在 VS 設,FS 原封不動輸出。

shaders/triangle.vert

#version 450
layout(location = 0) out vec3 vColor;

vec2 pos[3] = vec2[](
    vec2( 0.0, -0.6),
    vec2( 0.6,  0.6),
    vec2(-0.6,  0.6)
);
vec3 col[3] = vec3[](
    vec3(1.0, 0.2, 0.2),
    vec3(0.2, 1.0, 0.2),
    vec3(0.2, 0.4, 1.0)
);

void main() {
    gl_Position = vec4(pos[gl_VertexIndex], 0.0, 1.0);
    vColor = col[gl_VertexIndex];
}

shaders/triangle.frag

#version 450
layout(location = 0) in  vec3 vColor;
layout(location = 0) out vec4 outColor;
void main() { outColor = vec4(vColor, 1.0); }

glslc 編成 SPIR-V(Vulkan 要吃 SPIR-V):

glslc shaders/triangle.vert -o shaders/triangle.vert.spv
glslc shaders/triangle.frag -o shaders/triangle.frag.spv

小提醒

  • 建議把這兩行寫進你的 build scripts(例如 CMake custom command)。
  • 之後你也可以用 HLSL → DXC → SPIR-V;流程相同。

2)在程式裡建立 Shader Module(一次性、很短)

.spv 檔到記憶體,呼叫 vkCreateShaderModule。建立好後可馬上銷毀 Module,因為 Pipeline 會把內容吃進去。

#include <fstream>
static std::vector<char> readFile(const char* path) {
    std::ifstream f(path, std::ios::binary | std::ios::ate);
    if(!f) throw std::runtime_error("open file failed");
    size_t sz = (size_t)f.tellg();
    std::vector<char> buf(sz);
    f.seekg(0); f.read(buf.data(), sz);
    return buf;
}

VkShaderModule makeShaderModule(const std::vector<char>& code) {
    VkShaderModuleCreateInfo ci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
    ci.codeSize = code.size();
    ci.pCode    = reinterpret_cast<const uint32_t*>(code.data());
    VkShaderModule m{}; VK_CHECK(vkCreateShaderModule(device, &ci, nullptr, &m));
    return m;
}

3)建立 Pipeline Layout(這次不用任何資源)

本例先不使用 Descriptor/Push Constant,所以 Pipeline Layout 是空的:

VkPipelineLayout pipelineLayout{};
void createPipelineLayout() {
    VkPipelineLayoutCreateInfo ci{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};
    VK_CHECK(vkCreatePipelineLayout(device, &ci, nullptr, &pipelineLayout));
}

4)建立 Graphics Pipeline(關鍵設定一次看)

這一步把「著色器、輸入拓撲、視口/裁切、光柵化、混色……」打包成 VkPipeline
注意:必須指定 renderPasssubpass=0(我們不用 Dynamic Rendering)。

VkPipeline pipeline{};

void createGraphicsPipeline() {
    // 4.1 Shader stages
    auto vsCode = readFile("shaders/triangle.vert.spv");
    auto fsCode = readFile("shaders/triangle.frag.spv");
    VkShaderModule vert = makeShaderModule(vsCode);
    VkShaderModule frag = makeShaderModule(fsCode);

    VkPipelineShaderStageCreateInfo vs{VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};
    vs.stage  = VK_SHADER_STAGE_VERTEX_BIT;   vs.module = vert; vs.pName = "main";
    VkPipelineShaderStageCreateInfo fs{VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};
    fs.stage  = VK_SHADER_STAGE_FRAGMENT_BIT; fs.module = frag; fs.pName = "main";
    VkPipelineShaderStageCreateInfo stages[] = { vs, fs };

    // 4.2 Vertex input(本例不用頂點緩衝,統統空)
    VkPipelineVertexInputStateCreateInfo vi{VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO};

    // 4.3 組裝拓撲:三角形
    VkPipelineInputAssemblyStateCreateInfo ia{VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO};
    ia.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;

    // 4.4 視口/裁切
    VkViewport viewport{};
    viewport.x = 0; viewport.y = 0;
    viewport.width  = (float)swapExtent.width;
    viewport.height = (float)swapExtent.height;
    viewport.minDepth = 0.0f; viewport.maxDepth = 1.0f;

    VkRect2D scissor{{0,0}, swapExtent};
    VkPipelineViewportStateCreateInfo vp{VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO};
    vp.viewportCount = 1; vp.pViewports = &viewport;
    vp.scissorCount  = 1; vp.pScissors  = &scissor;

    // 4.5 光柵化(先不剔除背面,避免初學遇到繪製方向的困擾)
    VkPipelineRasterizationStateCreateInfo rs{VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO};
    rs.polygonMode = VK_POLYGON_MODE_FILL;
    rs.cullMode    = VK_CULL_MODE_NONE;
    rs.frontFace   = VK_FRONT_FACE_COUNTER_CLOCKWISE;
    rs.lineWidth   = 1.0f;

    // 4.6 取樣(本例不開 MSAA)
    VkPipelineMultisampleStateCreateInfo ms{VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO};
    ms.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;

    // 4.7 顏色混色(不混,直接覆蓋)
    VkPipelineColorBlendAttachmentState att{};
    att.colorWriteMask = VK_COLOR_COMPONENT_R_BIT|VK_COLOR_COMPONENT_G_BIT|
                         VK_COLOR_COMPONENT_B_BIT|VK_COLOR_COMPONENT_A_BIT;
    VkPipelineColorBlendStateCreateInfo bs{VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO};
    bs.attachmentCount = 1; bs.pAttachments = &att;

    // 4.8 組起來!
    VkGraphicsPipelineCreateInfo gp{VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO};
    gp.stageCount = 2; gp.pStages = stages;
    gp.pVertexInputState   = &vi;
    gp.pInputAssemblyState = &ia;
    gp.pViewportState      = &vp;
    gp.pRasterizationState = &rs;
    gp.pMultisampleState   = &ms;
    gp.pColorBlendState    = &bs;
    gp.layout              = pipelineLayout;
    gp.renderPass          = renderPass;   // ★ 傳統路徑要指定
    gp.subpass             = 0;

    VK_CHECK(vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &gp, nullptr, &pipeline));

    // Shader modules 用完即可釋放
    vkDestroyShaderModule(device, vert, nullptr);
    vkDestroyShaderModule(device, frag, nullptr);
}

為什麼不要剔除背面?
Vulkan 的「正面方向」與你設定的視口/數學手性有關。初學者常因繪製順序(CW/CCW)不一致,導致三角形被剔除。先把 cullMode=NONE,就不會被絆倒。


5)在 Command Buffer 裡「真的畫出來」

把你原本「清畫面」的 recordCmd(imageIndex) 改成下面版本:
沿用 Render Pass,不做任何 Dynamic Rendering 的函式)

void recordCmd(uint32_t imageIndex) {
    VkCommandBuffer cmd = cmdBufs[imageIndex];

    VkCommandBufferBeginInfo bi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
    bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
    VK_CHECK(vkBeginCommandBuffer(cmd, &bi));

    // 清為深藍色;Render Pass 會自動處理附件 layout 轉換
    VkClearValue clear; clear.color = {{0.05f, 0.25f, 0.9f, 1.0f}};
    VkRenderPassBeginInfo rbi{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO};
    rbi.renderPass  = renderPass;
    rbi.framebuffer = framebuffers[imageIndex];
    rbi.renderArea  = {{0,0}, swapExtent};
    rbi.clearValueCount = 1; rbi.pClearValues = &clear;

    vkCmdBeginRenderPass(cmd, &rbi, VK_SUBPASS_CONTENTS_INLINE);

    // 綁定 graphics pipeline,畫 3 個頂點
    vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
    vkCmdDraw(cmd, /*vertexCount*/3, /*instanceCount*/1, /*firstVertex*/0, /*firstInstance*/0);

    vkCmdEndRenderPass(cmd);
    VK_CHECK(vkEndCommandBuffer(cmd));
}

你的 drawFrame() 幀流程可以維持昨天那套(Acquire → Reset/Record → Submit → Present)—
只要把錄製這行換成上面新版的 recordCmd(imageIndex) 即可。

成果:
result


6)初始化與清理的呼叫順序(放在哪裡?)

建立階段(一次性)建議在 swapchain/FB 之後:

createPipelineLayout();
createGraphicsPipeline();

銷毀階段(與其他資源一起):

if (pipeline)       vkDestroyPipeline(device, pipeline, nullptr);
if (pipelineLayout) vkDestroyPipelineLayout(device, pipelineLayout, nullptr);

重建 swapchain(視窗大小變了):

  • 你已經知道要重建 swapchain → imageViews → framebuffers
  • 因為 swapExtent 改了(viewport/scissor 也跟著改),pipeline 也要重建(最簡單是:先砍掉舊的,再呼叫 createGraphicsPipeline() 取得新的)。

7)你可能會遇到的小錯與秒修法

  1. 畫面全黑

    • renderPass 與 pipeline 指定的 renderPass 不相容?請確認 pipeline 的 gp.renderPass = renderPass
    • 沒有呼叫 vkCmdBindPipelinevkCmdDraw
    • clearValueCount 數量與附件數不一致(本例 1 個 color)。
  2. Validation Layers 抱怨 subpass/attachment

    • 檢查 VkAttachmentDescriptionformat 是否等於你的 swapFormat
    • finalLayout 應為 VK_IMAGE_LAYOUT_PRESENT_SRC_KHR(給 Present 用)。
  3. 三角形不見/被剔除

    • cullMode = VK_CULL_MODE_NONE;或調整 frontFace/頂點順序。
  4. 視窗改大小後卡著

    • 收到 VK_ERROR_OUT_OF_DATE_KHRVK_SUBOPTIMAL_KHR 時,記得 vkDeviceWaitIdle,然後重建 swapchain、image views、framebuffers,以及 pipeline
  5. glslc 找不到

    • 安裝 LunarG Vulkan SDK(內含 glslc),或用你喜歡的編譯器(DXC)轉 SPIR-V。

8)5 分鐘進階練習

  • 改顏色:在 FS 用 sin(time) 做漸變色(把時間透過 push constant 傳進去;需要重建 pipeline layout)。
  • 加頂點緩衝:把三個頂點放到 VkBuffer,練習 vkCmdBindVertexBuffers
  • 開背面剔除:把 cullMode=BACK,然後試著調整頂點順序,看什麼時候會被剔掉。

9)一句話總結

畫第一個三角形的最核心步驟
「GLSL → SPIR-V → Shader Module → Pipeline Layout → Graphics Pipeline(綁定 render pass)→ 在 Render Pass 裡 BindPipeline + Draw(3)」。
這一步做完,你已經跨過 Vulkan 的第一個門檻。接著你能把頂點放進緩衝、加 Depth、加 Descriptor,往真正的場景邁進!


上一篇
Day 12|Vulkan 渲染流程:Command Buffer 與 Render Pass
下一篇
Day 14|Vulkan 載入模型並加入光影
系列文
渲染與GPU編程15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言