系统掌握从c语言到安卓全栈开发,计算机图形部份,从Android Framework 步入androd XR 虚拟现实打基础,我们开始学习vulkan

为了学习计算机图形,在后续学习安卓开发,
Vulkan 3D 图形零基础学习:(目标人群小学6年到到初中生)

我们决定从完全没接触过计算机图形的初学者(初中生也能看懂)。 使用方法:每一章先读「思路讲解」和「中文伪代码」,把流程在脑子里过一遍,再看真实代码。代码里每一行难懂的地方都加了中文注释。 十个例子的路线: 例1 画一条 2D 线 → 例2 画多条不同位置的线 → 例3 2D 动画 → 例4 每帧不断更新的 2D → 例5 简单 3D 立方体 → 例6 多个 3D 物体 → 例7 导入外部 3D 模型 → 例8 3D 动画 → 例9 纹理贴图 → 例10 光照综合场景。

第零章:先懂概念,再碰代码(最重要的一章)

很多新手一上来就抄代码,结果 Vulkan 一个程序七八百行,直接劝退。其实 Vulkan 代码之所以长,是因为它把「GPU 是怎么工作的」全部摆在了桌面上。你理解了下面这些概念,后面每个例子都只是这些概念的不同组合。

0.1 CPU 和 GPU 是两种不同的工人

  • CPU(中央处理器):像一个全能的老师傅,什么都会做,但只有 8~16 个人(核心)。适合干需要动脑子、一件一件来的活。
  • GPU(图形处理器):像一整个工厂的流水线工人,有几千个工人。每个人只会干非常简单的活(比如把一个数字乘以另一个数字),但几千人同时干,速度快得吓人。
屏幕上每一帧画面,是几百万个像素的颜色。计算这些颜色是"大量重复、互相独立"的活,正好适合 GPU 的流水线工厂。
Vulkan 是什么? Vulkan 就是你(CPU 上的程序)和 GPU 工厂之间的一套「订单系统」。你通过 Vulkan 向 GPU 下单:原料是什么(顶点数据)、按什么图纸加工(着色器)、成品放哪(屏幕/图片)。

0.2 画面是怎么来的:一条流水线

任何 3D 画面,本质上都是下面这条流水线流出来的,请记住这 5 步,十个例子全是它:
plain
 
顶点数据(一堆点的坐标)
   ↓ 第1步:顶点着色器 —— 决定每个点最终出现在屏幕哪个位置
图元装配(点连成线、线连成三角形)
   ↓ 第2步:光栅化 —— 算出线条/三角形盖住了屏幕上哪些像素
   ↓ 第3步:片段着色器 —— 决定每个被盖住的像素是什么颜色
   ↓ 第4步:深度测试/混合 —— 谁挡住谁、透明怎么叠
   ↓ 第5步:写进一张图片(帧缓冲),这张图片被翻上屏幕
用厨房打比方:
  • 顶点数据 = 切好的食材(土豆块)。
  • 顶点着色器 = 切配师傅,决定每块食材摆到盘子的哪个位置。
  • 光栅化 = 把盘子划分成小格子,看每块食材盖住哪些格子。
  • 片段着色器 = 调味师傅,给每个格子里的食物上色。
  • 帧缓冲 = 最终端上桌的盘子。
两个着色器(Shader)是你用代码写的两个小程序,它们不在 CPU 上跑,而是被送到 GPU 工厂里跑:
  • 顶点着色器:GPU 对每个顶点各跑一次。输入一个点的坐标,输出这个点最终在屏幕上的位置。
  • 片段着色器:GPU 对每个像素各跑一次。输出这个像素的颜色(红绿蓝透明度)。

0.3 几个绕不过去的名词

表格
 
 
名词 大白话解释
顶点(Vertex) 一个点,至少有 x,y,z 坐标,还可以带颜色、贴图坐标等"附属信息"
图元(Primitive) 点、线、三角形。GPU 只认识这三种基本图形。任何复杂模型都是成千上万三角形拼的
三角形 3D 图形的"积木"。因为三个点永远确定一个平面,GPU 最爱它
像素 / 片段(Fragment) 屏幕上的一个小格子。片段是"正在计算中的像素"
NDC 坐标 GPU 内部用的标准坐标:x、y、z 都在 -1 到 +1 之间。(-1,-1) 是屏幕左下角,(+1,+1) 是右上角
着色器语言 GLSL 写着色器用的语言(长得像 C 语言)
SPIR-V 着色器编译后的"机器码",Vulkan 只收这种格式

0.4 Vulkan 的"啰嗦"清单:一个程序要建哪些东西

Vulkan 的特点是:你问 GPU 做任何事之前,都要先把对应的"对象"创建出来。下面这张表是十个例子共同的主线,先混个眼熟,后面每章会反复见到:
表格
 
 
创建顺序 对象 大白话
1 Instance(实例) 和显卡驱动"握手",告诉驱动"我要用 Vulkan 了"
2 Surface(表面) 一块可以显示的"画布",关联到窗口
3 PhysicalDevice(物理设备) 选一块显卡(电脑里可能有多块)
4 Device / Queue(逻辑设备/队列) 向显卡开一个"办事窗口",以后所有命令都从这个窗口递交
5 Swapchain(交换链) 一组"备用画布"(通常 2~3 张图)。画完一张翻一张,防止画面撕裂
6 ImageView(图像视图) 给每张画布配一个"使用说明书"
7 RenderPass(渲染通道) 描述"这一帧画在哪里、画完怎么处理"
8 Pipeline(管线) 整条流水线的"总配置单":用什么着色器、画线还是画三角形、顶点数据长什么样
9 Buffer / Image(缓冲区/图像) GPU 上的"仓库",存顶点数据、贴图等
10 CommandBuffer(命令缓冲) "订单"。把"画这个"写成命令录进订单,再一次性提交给 GPU
11 同步对象(信号量/栅栏) 红绿灯系统。CPU 和 GPU 是两个独立干活的工人,需要红绿灯协调,防止抢同一张画布
重要心法:Vulkan 程序 = 「启动时一次性搭好上面 11 样东西」+「每帧循环:取一张画布 → 录命令 → 提交 → 翻画布」。十个例子的区别,只在第 8、9 两样(管线和数据)上变化,其余几乎不变。这就是规律,看懂例1,后面全是加减法。

0.5 开发环境准备(只做一次)

十个例子统一用 C++ + GLFW(负责开窗口)+ GLM(数学库,算矩阵):
bash
 
# Ubuntu 下安装依赖(一次就好)
sudo apt install vulkan-sdk libglfw3-dev libglm-dev glslc
# 验证显卡驱动支持 Vulkan:
vulkaninfo --summary
编译命令统一为(后面不再重复):
bash
 
# 1. 把 GLSL 着色器源码编译成 SPIR-V 机器码
glslc shader.vert -o vert.spv
glslc shader.frag -o frag.spv
# 2. 编译主程序
g++ -std=c++17 main.cpp -o app -lvulkan -lglfw

例1:画一条 2D 线(人生第一个 Vulkan 程序)

1.1 目标

在窗口正中央画一条水平白线,从左 (-0.8, 0.0) 到右 (0.8, 0.0)。

1.2 本章新概念

只需要两个顶点、把管线的"图元类型"设为线(LINE_LIST)。其余全是第零章那张"啰嗦清单"的第一次完整实践。

1.3 中文伪代码流程(先背下来)

plain
 
程序启动:
  开窗口(GLFW)
  创建 Instance            —— 和显卡驱动握手
  创建 Surface             —— 把窗口变成 Vulkan 能画画的画布
  选显卡 PhysicalDevice    —— 遍历所有显卡,挑一块支持画图的
  创建 Device + Queue      —— 开办事窗口
  创建 Swapchain           —— 申请 2~3 张备用画布
  给每张画布建 ImageView
  创建 RenderPass          —— 声明:画之前清空为黑色,画完拿去显示
  编译两个着色器(GLSL→SPIR-V),创建 Pipeline
      —— 重点:图元类型 = 线;顶点格式 = 每个顶点3个float(x,y,z)
  创建 Framebuffer         —— 把画布按 RenderPass 的要求组装好
  创建 CommandPool、CommandBuffer —— 准备写订单的本子
  创建 VertexBuffer        —— 把 2 个顶点(6个float)上传进 GPU 仓库
  创建信号量 + 栅栏        —— 装好红绿灯

每帧循环(drawFrame):
  等上一帧的栅栏(绿灯亮才能动)
  从交换链领一张空闲画布
  开始录命令到 CommandBuffer:
      开始 RenderPass(清屏为黑)
      绑定 Pipeline(告诉 GPU 用哪套流水线配置)
      绑定 VertexBuffer(告诉 GPU 原料在哪)
      vkCmdDraw(2个顶点) —— 下单:画2个点,按"线"的方式装配
      结束 RenderPass
  结束录制,提交订单给 GPU 队列
  把画好的画布翻上屏幕
  窗口要关就退出循环

程序退出:
  等 GPU 干完活,按创建的反序销毁所有对象
规律预告:上面 90% 的内容在例2~例10 中原封不动。变的只是:顶点变多、图元类型变三角形、加矩阵、加贴图。

1.4 着色器代码(2 个小文件)

shader.vert(顶点着色器)——每个顶点跑一次,把坐标原样传给 GPU:
glsl
 
#version 450
// layout(location=0) 表示:这是顶点数据的第0号属性,对应C++里绑定的位置
layout(location = 0) in vec3 inPosition;   // 输入:顶点的坐标 (x,y,z)

void main() {
    // gl_Position 是固定输出:这个顶点最终在屏幕上的位置
    // vec4 的第四个分量 w 是透视除法用的,现在先写死 1.0
    gl_Position = vec4(inPosition, 1.0);
}
shader.frag(片段着色器)——每个被线盖住的像素跑一次,输出白色:
glsl
 
#version 450
layout(location = 0) out vec4 outColor;    // 输出:像素颜色(红,绿,蓝,透明度),范围0~1

void main() {
    outColor = vec4(1.0, 1.0, 1.0, 1.0);   // 纯白
}
编译成 SPIR-V:
bash
 
glslc shader.vert -o vert.spv
glslc shader.frag -o frag.spv

1.5 完整真实代码 main.cpp(带详细注释)

cpp
 
// ============================================================
// 例1:画一条 2D 线
// 编译: g++ -std=c++17 main.cpp -o app -lvulkan -lglfw
// ============================================================
#define GLFW_INCLUDE_VULKAN          // 让 GLFW 自动带上 Vulkan 头文件
#include <GLFW/glfw3.h>
#include <iostream>
#include <fstream>
#include <vector>
#include <cstring>
#include <stdexcept>

const int WIDTH  = 800;              // 窗口宽
const int HEIGHT = 600;              // 窗口高

// ---------- 工具函数:读取 SPIR-V 文件 ----------
// 着色器编译产物是二进制文件,读进来交给 Vulkan
static std::vector<char> readFile(const std::string& filename) {
    std::ifstream file(filename, std::ios::ate | std::ios::binary); // ate=直接跳到文件末尾
    if (!file.is_open()) throw std::runtime_error("打不开文件: " + filename);
    size_t fileSize = (size_t)file.tellg();          // 末尾位置 = 文件大小
    std::vector<char> buffer(fileSize);
    file.seekg(0);                                   // 回到开头
    file.read(buffer.data(), fileSize);
    return buffer;
}

class HelloLine {
public:
    void run() {
        initWindow();      // 第一步:开窗口
        initVulkan();      // 第二步:搭好全部 Vulkan 设施
        mainLoop();        // 第三步:每帧画画
        cleanup();         // 第四步:打扫战场
    }

private:
    // ---------------- 成员变量:第零章清单的实体 ----------------
    GLFWwindow* window;                      // 窗口
    VkInstance instance;                     // 1. 与驱动握手
    VkSurfaceKHR surface;                    // 2. 画布(关联窗口)
    VkPhysicalDevice physicalDevice;         // 3. 选中的显卡
    VkDevice device;                         // 4. 逻辑设备(办事窗口)
    VkQueue graphicsQueue;                   // 4. 递交命令的队列
    VkSwapchainKHR swapchain;                // 5. 交换链(备用画布组)
    std::vector<VkImage> swapchainImages;    // 5. 交换链里的每张画布
    VkFormat swapchainFormat;                // 画布的颜色格式
    VkExtent2D swapchainExtent;              // 画布的尺寸
    std::vector<VkImageView> swapchainImageViews; // 6. 画布视图
    VkRenderPass renderPass;                 // 7. 渲染通道
    VkPipelineLayout pipelineLayout;         // 管线布局(着色器的外部接口描述)
    VkPipeline pipeline;                     // 8. 流水线总配置
    std::vector<VkFramebuffer> framebuffers; // 组装好的帧缓冲
    VkCommandPool commandPool;               // 订单本子的"管理员"
    VkCommandBuffer commandBuffer;           // 10. 订单(本例只录一次反复用)
    VkBuffer vertexBuffer;                   // 9. 顶点仓库
    VkDeviceMemory vertexBufferMemory;       // 仓库的内存
    VkSemaphore imageAvailable;              // 红绿灯:画布领到了
    VkSemaphore renderFinished;              // 红绿灯:画完了
    VkFence inFlight;                        // 栅栏:CPU 等 GPU 的闸门

    // ---------------- 顶点数据:一条线 = 2 个点 ----------------
    // 每个顶点 3 个 float:x, y, z(NDC 坐标,范围 -1~1)
    float vertices[6] = {
        -0.8f, 0.0f, 0.0f,   // 左端点
         0.8f, 0.0f, 0.0f    // 右端点
    };

    // ================== 第一步:开窗口 ==================
    void initWindow() {
        glfwInit();
        glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); // 关键:告诉GLFW别创建OpenGL,我们用Vulkan
        window = glfwCreateWindow(WIDTH, HEIGHT, "例1: 一条线", nullptr, nullptr);
    }

    // ================== 第二步:搭 Vulkan(重头戏) ==================
    void initVulkan() {
        createInstance();
        createSurface();
        pickPhysicalDevice();
        createLogicalDevice();
        createSwapchain();
        createImageViews();
        createRenderPass();
        createPipeline();
        createFramebuffers();
        createCommandPool();
        createVertexBuffer();
        createCommandBuffer();
        createSyncObjects();
    }

    // ---- 1. Instance:和驱动握手 ----
    void createInstance() {
        VkApplicationInfo appInfo{};
        appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
        appInfo.pApplicationName = "HelloLine";
        appInfo.apiVersion = VK_API_VERSION_1_2;

        // Vulkan 不直接和窗口打交道,需要 GLFW 提供的扩展
        uint32_t glfwExtCount = 0;
        const char** glfwExts = glfwGetRequiredInstanceExtensions(&glfwExtCount);

        VkInstanceCreateInfo createInfo{};
        createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
        createInfo.pApplicationInfo = &appInfo;
        createInfo.enabledExtensionCount = glfwExtCount;
        createInfo.ppEnabledExtensionNames = glfwExts;
        createInfo.enabledLayerCount = 0;   // 教程省略校验层(调试用),正式学习建议加上

        if (vkCreateInstance(&createInfo, nullptr, &instance) != VK_SUCCESS)
            throw std::runtime_error("创建 Instance 失败");
    }

    // ---- 2. Surface:窗口变成画布 ----
    void createSurface() {
        if (glfwCreateWindowSurface(instance, window, nullptr, &surface) != VK_SUCCESS)
            throw std::runtime_error("创建 Surface 失败");
    }

    // ---- 3. 选显卡:找一块"既能画图又能显示到这个surface"的 ----
    void pickPhysicalDevice() {
        uint32_t count = 0;
        vkEnumeratePhysicalDevices(instance, &count, nullptr);
        if (count == 0) throw std::runtime_error("没有支持Vulkan的显卡");
        std::vector<VkPhysicalDevice> devices(count);
        vkEnumeratePhysicalDevices(instance, &count, devices.data());
        physicalDevice = devices[0];  // 简化教学:直接用第一块。正式代码要检查队列族支持
    }

    // ---- 4. 逻辑设备 + 队列:开办事窗口 ----
    void createLogicalDevice() {
        // 找支持"图形命令"的队列族编号(简化:假设第0个族就行,正式代码要遍历检查)
        uint32_t queueFamily = 0;
        float priority = 1.0f;
        VkDeviceQueueCreateInfo queueInfo{};
        queueInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
        queueInfo.queueFamilyIndex = queueFamily;
        queueInfo.queueCount = 1;
        queueInfo.pQueuePriorities = &priority;

        // 交换链是"设备扩展",必须显式开启
        const char* deviceExts[] = { VK_KHR_SWAPCHAIN_EXTENSION_NAME };

        VkDeviceCreateInfo createInfo{};
        createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
        createInfo.pQueueCreateInfos = &queueInfo;
        createInfo.queueCreateInfoCount = 1;
        createInfo.enabledExtensionCount = 1;
        createInfo.ppEnabledExtensionNames = deviceExts;

        if (vkCreateDevice(physicalDevice, &createInfo, nullptr, &device) != VK_SUCCESS)
            throw std::runtime_error("创建逻辑设备失败");
        vkGetDeviceQueue(device, queueFamily, 0, &graphicsQueue); // 拿到队列句柄
    }

    // ---- 5. 交换链:申请备用画布 ----
    void createSwapchain() {
        VkSwapchainCreateInfoKHR createInfo{};
        createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
        createInfo.surface = surface;
        createInfo.minImageCount = 2;                    // 双缓冲:一张显示一张画
        createInfo.imageFormat = VK_FORMAT_B8G8R8A8_SRGB;// 每像素4字节的颜色格式
        createInfo.imageColorSpace = VK_COLOR_SPACE_SRGB_NONLINEAR_KHR;
        createInfo.imageExtent = { (uint32_t)WIDTH, (uint32_t)HEIGHT };
        createInfo.imageArrayLayers = 1;
        createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; // 用途:被渲染上去
        createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
        createInfo.preTransform = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR;
        createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
        createInfo.presentMode = VK_PRESENT_MODE_FIFO_KHR; // 垂直同步模式,最稳
        createInfo.clipped = VK_TRUE;

        if (vkCreateSwapchainKHR(device, &createInfo, nullptr, &swapchain) != VK_SUCCESS)
            throw std::runtime_error("创建交换链失败");

        // 创建后要查询到底给了我们几张画布
        uint32_t imageCount;
        vkGetSwapchainImagesKHR(device, swapchain, &imageCount, nullptr);
        swapchainImages.resize(imageCount);
        vkGetSwapchainImagesKHR(device, swapchain, &imageCount, swapchainImages.data());

        swapchainFormat = createInfo.imageFormat;
        swapchainExtent = createInfo.imageExtent;
    }

    // ---- 6. ImageView:每张画布的"使用说明书" ----
    void createImageViews() {
        swapchainImageViews.resize(swapchainImages.size());
        for (size_t i = 0; i < swapchainImages.size(); i++) {
            VkImageViewCreateInfo createInfo{};
            createInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
            createInfo.image = swapchainImages[i];
            createInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;     // 当2D图用
            createInfo.format = swapchainFormat;
            createInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
            createInfo.subresourceRange.levelCount = 1;
            createInfo.subresourceRange.layerCount = 1;
            vkCreateImageView(device, &createInfo, nullptr, &swapchainImageViews[i]);
        }
    }

    // ---- 7. RenderPass:声明"这一帧怎么画" ----
    void createRenderPass() {
        VkAttachmentDescription colorAttachment{};
        colorAttachment.format = swapchainFormat;
        colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT;   // 不开抗锯齿多重采样
        colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;  // 画之前:清空
        colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;// 画完:保留
        colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
        colorAttachment.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; // 画完去显示

        VkAttachmentReference colorRef{};
        colorRef.attachment = 0;
        colorRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;

        VkSubpassDescription subpass{};
        subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
        subpass.colorAttachmentCount = 1;
        subpass.pColorAttachments = &colorRef;

        VkRenderPassCreateInfo renderPassInfo{};
        renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
        renderPassInfo.attachmentCount = 1;
        renderPassInfo.pAttachments = &colorAttachment;
        renderPassInfo.subpassCount = 1;
        renderPassInfo.pSubpasses = &subpass;

        if (vkCreateRenderPass(device, &renderPassInfo, nullptr, &renderPass) != VK_SUCCESS)
            throw std::runtime_error("创建RenderPass失败");
    }

    // ---- 把 SPIR-V 包装成 Vulkan 着色器模块的小工具 ----
    VkShaderModule createShaderModule(const std::vector<char>& code) {
        VkShaderModuleCreateInfo createInfo{};
        createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
        createInfo.codeSize = code.size();
        createInfo.pCode = reinterpret_cast<const uint32_t*>(code.data());
        VkShaderModule shaderModule;
        vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule);
        return shaderModule;
    }

    // ---- 8. Pipeline:整条流水线的总配置单(本章核心) ----
    void createPipeline() {
        // 读入两个着色器的 SPIR-V 机器码
        auto vertCode = readFile("vert.spv");
        auto fragCode = readFile("frag.spv");
        VkShaderModule vertModule = createShaderModule(vertCode);
        VkShaderModule fragModule = createShaderModule(fragCode);

        // 把着色器挂到流水线的对应阶段
        VkPipelineShaderStageCreateInfo vertStage{};
        vertStage.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
        vertStage.stage = VK_SHADER_STAGE_VERTEX_BIT;    // 顶点着色器阶段
        vertStage.module = vertModule;
        vertStage.pName = "main";                        // 入口函数名

        VkPipelineShaderStageCreateInfo fragStage{};
        fragStage.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
        fragStage.stage = VK_SHADER_STAGE_FRAGMENT_BIT;  // 片段着色器阶段
        fragStage.module = fragModule;
        fragStage.pName = "main";
        VkPipelineShaderStageCreateInfo stages[] = { vertStage, fragStage };

        // ★关键1:顶点数据格式说明书 —— 每个顶点 = 3个float,共12字节
        VkVertexInputBindingDescription binding{};
        binding.binding = 0;                             // 绑定到0号槽
        binding.stride = 3 * sizeof(float);              // 相邻两个顶点隔12字节
        binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;

        VkVertexInputAttributeDescription attribute{};
        attribute.binding = 0;
        attribute.location = 0;                          // 对应GLSL的 layout(location=0)
        attribute.format = VK_FORMAT_R32G32B32_SFLOAT;   // 3个float = xyz
        attribute.offset = 0;

        VkPipelineVertexInputStateCreateInfo vertexInput{};
        vertexInput.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
        vertexInput.vertexBindingDescriptionCount = 1;
        vertexInput.pVertexBindingDescriptions = &binding;
        vertexInput.vertexAttributeDescriptionCount = 1;
        vertexInput.pVertexAttributeDescriptions = &attribute;

        // ★关键2:图元类型 = 线!(例5 改成三角形就是3D了)
        VkPipelineInputAssemblyStateCreateInfo inputAssembly{};
        inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
        inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_LINE_LIST; // ← 每2个顶点连一条线

        // 视口:把 NDC(-1~1) 映射到实际像素范围
        VkViewport viewport{};
        viewport.x = 0; viewport.y = 0;
        viewport.width = (float)WIDTH; viewport.height = (float)HEIGHT;
        viewport.minDepth = 0.0f; viewport.maxDepth = 1.0f;
        VkRect2D scissor{};
        scissor.offset = {0, 0};
        scissor.extent = swapchainExtent;
        VkPipelineViewportStateCreateInfo viewportState{};
        viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
        viewportState.viewportCount = 1;
        viewportState.pViewports = &viewport;
        viewportState.scissorCount = 1;
        viewportState.pScissors = &scissor;

        // 光栅化器设置:怎么把线/三角形变成像素
        VkPipelineRasterizationStateCreateInfo rasterizer{};
        rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
        rasterizer.polygonMode = VK_POLYGON_MODE_FILL;   // 实心填充
        rasterizer.lineWidth = 1.0f;                     // ★线的粗细(单位:像素)
        rasterizer.cullMode = VK_CULL_MODE_NONE;         // 不做背面剔除
        rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;

        // 多重采样:关闭
        VkPipelineMultisampleStateCreateInfo multisampling{};
        multisampling.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
        multisampling.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;

        // 颜色混合:直接覆盖,不做透明混合
        VkPipelineColorBlendAttachmentState colorBlendAttachment{};
        colorBlendAttachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT
                                            | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
        colorBlendAttachment.blendEnable = VK_FALSE;
        VkPipelineColorBlendStateCreateInfo colorBlending{};
        colorBlending.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
        colorBlending.attachmentCount = 1;
        colorBlending.pAttachments = &colorBlendAttachment;

        // 管线布局:描述着色器用到的外部资源(本例没有,所以是空的)
        VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
        pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
        vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, &pipelineLayout);

        // 汇总所有配置,创建管线
        VkGraphicsPipelineCreateInfo pipelineInfo{};
        pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
        pipelineInfo.stageCount = 2;
        pipelineInfo.pStages = stages;
        pipelineInfo.pVertexInputState = &vertexInput;
        pipelineInfo.pInputAssemblyState = &inputAssembly;
        pipelineInfo.pViewportState = &viewportState;
        pipelineInfo.pRasterizationState = &rasterizer;
        pipelineInfo.pMultisampleState = &multisampling;
        pipelineInfo.pColorBlendState = &colorBlending;
        pipelineInfo.layout = pipelineLayout;
        pipelineInfo.renderPass = renderPass;
        pipelineInfo.subpass = 0;

        if (vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &pipeline) != VK_SUCCESS)
            throw std::runtime_error("创建Pipeline失败");

        // 管线建好后,着色器模块就没用了,可以销毁
        vkDestroyShaderModule(device, vertModule, nullptr);
        vkDestroyShaderModule(device, fragModule, nullptr);
    }

    // ---- 帧缓冲:把画布按 RenderPass 要求组装 ----
    void createFramebuffers() {
        framebuffers.resize(swapchainImageViews.size());
        for (size_t i = 0; i < swapchainImageViews.size(); i++) {
            VkFramebufferCreateInfo fbInfo{};
            fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
            fbInfo.renderPass = renderPass;
            fbInfo.attachmentCount = 1;
            fbInfo.pAttachments = &swapchainImageViews[i];
            fbInfo.width = swapchainExtent.width;
            fbInfo.height = swapchainExtent.height;
            fbInfo.layers = 1;
            vkCreateFramebuffer(device, &fbInfo, nullptr, &framebuffers[i]);
        }
    }

    // ---- 命令池:管理"订单本子" ----
    void createCommandPool() {
        VkCommandPoolCreateInfo poolInfo{};
        poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
        poolInfo.queueFamilyIndex = 0;
        poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
        vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool);
    }

    // ---- 9. 顶点缓冲:把 2 个顶点传进 GPU ----
    void createVertexBuffer() {
        VkDeviceSize size = sizeof(vertices);   // 6个float = 24字节

        // 创建缓冲区对象
        VkBufferCreateInfo bufferInfo{};
        bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
        bufferInfo.size = size;
        bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT; // 用途:顶点缓冲
        bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
        vkCreateBuffer(device, &bufferInfo, nullptr, &vertexBuffer);

        // 查询这块缓冲需要什么样的内存,然后分配
        VkMemoryRequirements memReq;
        vkGetBufferMemoryRequirements(device, vertexBuffer, &memReq);
        VkMemoryAllocateInfo allocInfo{};
        allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
        allocInfo.allocationSize = memReq.size;
        // 简化教学:申请"CPU可见"的内存类型0。正式代码要按 memReq.memoryTypeBits
        // 和 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 筛选类型
        allocInfo.memoryTypeIndex = 0;
        vkAllocateMemory(device, &allocInfo, nullptr, &vertexBufferMemory);
        vkBindBufferMemory(device, vertexBuffer, vertexBufferMemory, 0);

        // 把 CPU 里的顶点数据拷贝进 GPU 内存
        void* data;
        vkMapMemory(device, vertexBufferMemory, 0, size, 0, &data); // 映射:拿到GPU内存的指针
        memcpy(data, vertices, size);                               // 拷贝
        vkUnmapMemory(device, vertexBufferMemory);                  // 解除映射
    }

    // ---- 10. 录命令到订单 ----
    void createCommandBuffer() {
        VkCommandBufferAllocateInfo allocInfo{};
        allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
        allocInfo.commandPool = commandPool;
        allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
        allocInfo.commandBufferCount = 1;
        vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
    }

    // 每帧都重新录一遍命令(后面例3例4改数据时才需要,例1其实录一次就够,
    // 但为了和后面例子保持同一结构,这里每帧录)
    void recordCommandBuffer(VkCommandBuffer cmd, uint32_t imageIndex) {
        VkCommandBufferBeginInfo beginInfo{};
        beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
        vkBeginCommandBuffer(cmd, &beginInfo);                 // 开始录

        VkRenderPassBeginInfo renderPassInfo{};
        renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
        renderPassInfo.renderPass = renderPass;
        renderPassInfo.framebuffer = framebuffers[imageIndex]; // 画到领来的那张画布
        renderPassInfo.renderArea.offset = {0, 0};
        renderPassInfo.renderArea.extent = swapchainExtent;
        VkClearValue clearColor = {{{0.0f, 0.0f, 0.0f, 1.0f}}};// 清屏色 = 黑色
        renderPassInfo.clearValueCount = 1;
        renderPassInfo.pClearValues = &clearColor;

        vkCmdBeginRenderPass(cmd, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE);
        vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); // 用这套流水线
        VkDeviceSize offsets[] = {0};
        vkCmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer, offsets);         // 原料在这
        vkCmdDraw(cmd, 2, 1, 0, 0);   // ★下单:画2个顶点、1个实例
        vkCmdEndRenderPass(cmd);

        vkEndCommandBuffer(cmd);                             // 录完
    }

    // ---- 11. 红绿灯 ----
    void createSyncObjects() {
        VkSemaphoreCreateInfo semInfo{};
        semInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
        VkFenceCreateInfo fenceInfo{};
        fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
        fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT;  // 初始就是"已通过"状态,第一帧不会卡死
        vkCreateSemaphore(device, &semInfo, nullptr, &imageAvailable);
        vkCreateSemaphore(device, &semInfo, nullptr, &renderFinished);
        vkCreateFence(device, &fenceInfo, nullptr, &inFlight);
    }

    // ================== 第三步:每帧循环 ==================
    void drawFrame() {
        vkWaitForFences(device, 1, &inFlight, VK_TRUE, UINT64_MAX); // 等上一帧GPU画完
        vkResetFences(device, 1, &inFlight);

        uint32_t imageIndex;
        vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, imageAvailable,
                              VK_NULL_HANDLE, &imageIndex);        // 领画布

        vkResetCommandBuffer(commandBuffer, 0);
        recordCommandBuffer(commandBuffer, imageIndex);            // 写订单

        // 提交订单:等 imageAvailable 绿灯才开工,画完亮 renderFinished 绿灯
        VkSubmitInfo submitInfo{};
        submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
        VkPipelineStageFlags waitStages[] = { VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT };
        submitInfo.waitSemaphoreCount = 1;
        submitInfo.pWaitSemaphores = &imageAvailable;
        submitInfo.pWaitDstStageMask = waitStages;
        submitInfo.commandBufferCount = 1;
        submitInfo.pCommandBuffers = &commandBuffer;
        submitInfo.signalSemaphoreCount = 1;
        submitInfo.pSignalSemaphores = &renderFinished;
        vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlight);

        // 翻画布:等 renderFinished 绿灯才把画好的图送上屏幕
        VkPresentInfoKHR presentInfo{};
        presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
        presentInfo.waitSemaphoreCount = 1;
        presentInfo.pWaitSemaphores = &renderFinished;
        presentInfo.swapchainCount = 1;
        presentInfo.pSwapchains = &swapchain;
        presentInfo.pImageIndices = &imageIndex;
        vkQueuePresentKHR(graphicsQueue, presentInfo);
    }

    void mainLoop() {
        while (!glfwWindowShouldClose(window)) {
            glfwPollEvents();  // 处理键盘鼠标窗口事件
            drawFrame();
        }
        vkDeviceWaitIdle(device);  // 退出前等GPU干完所有活
    }

    // ================== 第四步:按创建的反序销毁 ==================
    void cleanup() {
        vkDestroySemaphore(device, imageAvailable, nullptr);
        vkDestroySemaphore(device, renderFinished, nullptr);
        vkDestroyFence(device, inFlight, nullptr);
        vkDestroyBuffer(device, vertexBuffer, nullptr);
        vkFreeMemory(device, vertexBufferMemory, nullptr);
        vkDestroyCommandPool(device, commandPool, nullptr);
        for (auto fb : framebuffers) vkDestroyFramebuffer(device, fb, nullptr);
        vkDestroyPipeline(device, pipeline, nullptr);
        vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
        vkDestroyRenderPass(device, renderPass, nullptr);
        for (auto v : swapchainImageViews) vkDestroyImageView(device, v, nullptr);
        vkDestroySwapchainKHR(device, swapchain, nullptr);
        vkDestroyDevice(device, nullptr);
        vkDestroySurfaceKHR(instance, surface, nullptr);
        vkDestroyInstance(instance, nullptr);
        glfwDestroyWindow(window);
        glfwTerminate();
    }
};

int main() {
    HelloLine app;
    try { app.run(); }
    catch (const std::exception& e) { std::cerr << e.what() << std::endl; return 1; }
    return 0;
}

1.6 本章规律总结

  1. 程序骨架四段式:initWindow → initVulkan → mainLoop → cleanup,十个例子全一样。
  2. initVulkan 的 12 步顺序基本固定,只有"管线配置"和"数据上传"两步在各例子间变化。
  3. 每帧固定动作:等栅栏 → 领画布 → 录命令 → 提交 → 呈现。
  4. 画线还是画三角形,只差管线里一个枚举 topology 的值。

例2:画多条位置不同的彩色线

2.1 目标

屏幕上画一个彩色的"米"字形:4 条线段(8 个顶点),每条线颜色不同。

2.2 本章新概念

  1. 顶点带"附属信息":顶点不只有坐标,还可以带颜色。GPU 会自动把两个端点的颜色在中间的每个像素上插值渐变(从红渐变到蓝),这是免费得到的效果。
  2. 顶点数据格式变了:原来每个顶点 3 个 float(坐标),现在 6 个 float(坐标+颜色)。管线的"顶点格式说明书"要跟着改。
  3. 着色器之间传值:顶点着色器把颜色"递"给片段着色器(用 out / in 配对)。

2.3 中文伪代码流程

plain
 
启动流程:和例1一模一样,只有三处变化:
  变化1:顶点数据 = 8个顶点,每个顶点 = [x, y, z, r, g, b] 共6个float
  变化2:管线顶点格式说明书 = 2条属性:
        属性0:坐标(3个float,偏移0)
        属性1:颜色(3个float,偏移12字节)
  变化3:画图命令 vkCmdDraw(8个顶点)  ← 例1是2个

每帧循环:完全不变(因为数据是静态的,画出来的画面每帧相同)

2.4 着色器变化

shader.vert——多了一个颜色输入,并把它递交给下一阶段:
glsl
 
#version 450
layout(location = 0) in vec3 inPosition;  // 0号属性:坐标
layout(location = 1) in vec3 inColor;     // 1号属性:颜色
layout(location = 0) out vec3 fragColor;  // 递交给片段着色器(location要配对)

void main() {
    gl_Position = vec4(inPosition, 1.0);
    fragColor = inColor;   // 原样传递。GPU会在两个顶点之间自动渐变插值
}
shader.frag——接收递来的颜色,直接涂到像素上:
glsl
 
#version 450
layout(location = 0) in vec3 fragColor;   // 接收顶点着色器递来的颜色(已插值)
layout(location = 0) out vec4 outColor;

void main() {
    outColor = vec4(fragColor, 1.0);      // 用递来的颜色画像素
}

2.5 C++ 代码变化(只列出和例1不同的部分)

cpp
 
// ---------- 变化1:顶点数据,8个顶点 = 4条线段 ----------
// 每个顶点6个float:x, y, z, r, g, b
float vertices[] = {
    // 横线:红 → 黄
    -0.8f,  0.0f, 0.0f,   1.0f, 0.0f, 0.0f,
     0.8f,  0.0f, 0.0f,   1.0f, 1.0f, 0.0f,
    // 竖线:绿 → 青
     0.0f, -0.8f, 0.0f,   0.0f, 1.0f, 0.0f,
     0.0f,  0.8f, 0.0f,   0.0f, 1.0f, 1.0f,
    // 斜线1:蓝 → 紫
    -0.6f, -0.6f, 0.0f,   0.0f, 0.0f, 1.0f,
     0.6f,  0.6f, 0.0f,   1.0f, 0.0f, 1.0f,
    // 斜线2:白 → 橙
    -0.6f,  0.6f, 0.0f,   1.0f, 1.0f, 1.0f,
     0.6f, -0.6f, 0.0f,   1.0f, 0.5f, 0.0f,
};
const uint32_t VERTEX_COUNT = 8;   // 画图命令要用
cpp
 
// ---------- 变化2:createPipeline() 里的顶点格式说明书 ----------
void createPipeline() {
    // ...着色器部分和例1完全一样,省略...

    // 绑定说明:每个顶点现在占 6*4 = 24 字节
    VkVertexInputBindingDescription binding{};
    binding.binding = 0;
    binding.stride = 6 * sizeof(float);          // ★ 3→6
    binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;

    // 两条属性:坐标 + 颜色
    VkVertexInputAttributeDescription attributes[2]{};
    // 属性0:坐标,对应GLSL location=0
    attributes[0].binding = 0;
    attributes[0].location = 0;
    attributes[0].format = VK_FORMAT_R32G32B32_SFLOAT;  // 3个float
    attributes[0].offset = 0;                           // 在顶点数据里的起始位置:第0字节
    // 属性1:颜色,对应GLSL location=1
    attributes[1].binding = 0;
    attributes[1].location = 1;
    attributes[1].format = VK_FORMAT_R32G32B32_SFLOAT;  // 3个float
    attributes[1].offset = 3 * sizeof(float);           // 起始位置:跳过前3个float(12字节)

    VkPipelineVertexInputStateCreateInfo vertexInput{};
    vertexInput.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
    vertexInput.vertexBindingDescriptionCount = 1;
    vertexInput.pVertexBindingDescriptions = &binding;
    vertexInput.vertexAttributeDescriptionCount = 2;    // ★ 1→2
    vertexInput.pVertexAttributeDescriptions = attributes;

    // 其余(图元类型=LINE_LIST、视口、光栅化...)全部和例1相同,省略
}
cpp
 
// ---------- 变化3:画图命令的顶点数 ----------
// recordCommandBuffer 里:
vkCmdDraw(cmd, VERTEX_COUNT, 1, 0, 0);   // 8个顶点 → 按LINE_LIST每2个连成1条线 → 4条线

2.6 本章规律总结

  1. 顶点数据是"数组",格式说明书告诉 GPU 怎么切:stride 是每个顶点占多少字节,offset 是每种信息在顶点内部的起始位置。这就是"顶点格式"的全部秘密。
  2. GLSL 的 location 编号 ↔ C++ 的 attribute.location 必须一一对应,对不上就花屏或报错。
  3. GPU 免费做插值:线段两端颜色不同,中间自动渐变。以后 3D 模型的平滑光照、纹理贴图全靠这个机制。
  4. 想画更多线?往数组里继续加顶点就行,框架一行不用改。数据驱动——这是 GPU 编程的核心思想。

例3:2D 动画——让图形动起来

3.1 目标

一个三角形绕屏幕中心不停旋转。

3.2 本章新概念

动画的本质:每帧的画面都不一样。让画面变化有三条路:
  1. 每帧改顶点数据(CPU 改,慢,留给例4);
  2. 每帧给着色器传一个"参数"(比如旋转角度),让 GPU 在顶点着色器里算出不同位置(快,本章学这个);
  3. 每帧改画图命令(比如画不同数量的顶点)。
本章走第 2 条路。给着色器传参数的工具叫 Uniform Buffer(统一缓冲区):一块所有顶点共享的"公告栏",CPU 每帧往上面写最新数值,GPU 画每个顶点时都读它。
配套概念:
  • 矩阵(Matrix):一个 4×4 的数字表格,把它乘以顶点坐标,就能完成旋转、平移、缩放。你不用自己算,GLM 库帮你生成。
  • 描述符(Descriptor):告诉着色器"公告栏在哪块内存"的门牌号。流程是:建 Buffer → 建 DescriptorSetLayout(公告栏格式)→ 建 DescriptorPool(门牌号发放处)→ 分配 DescriptorSet(领门牌号并绑定)→ 管线路由 PipelineLayout 引用它。

3.3 中文伪代码流程

plain
 
启动(在例2基础上增加):
  顶点数据 = 一个三角形(3个顶点,z都=0)
  图元类型改成 TRIANGLE_LIST          ← 第一次画实心三角形!
  创建 UniformBuffer:一块 64字节 的 GPU 内存(一个4x4矩阵的大小)
  创建 DescriptorSetLayout:声明"顶点着色器要用1个uniform块,绑定点0"
  创建 DescriptorPool 并分配 DescriptorSet,把它指向 UniformBuffer
  PipelineLayout 里挂上这个 Layout(否则着色器收不到公告栏)

每帧循环(新增两步):
  计算:这一帧的旋转角度 = 程序运行时间 × 速度
  用GLM生成旋转矩阵 model = rotate(角度, 绕z轴)
  memcpy 把矩阵写进 UniformBuffer 的映射内存    ← 唯一每帧更新的东西
  录命令时多一句:绑定 DescriptorSet
  vkCmdDraw(3个顶点)
关键理解:顶点数据从头到尾没改过!变的是"公告栏"里的矩阵。顶点着色器每帧用新矩阵去乘同一份顶点坐标,算出的屏幕位置就不同了 → 画面动了。这就是"GPU 动画"的基本原理:数据不动,参数动。

3.4 着色器变化

shader.vert——接收公告栏里的矩阵,先乘矩阵再输出:
glsl
 
#version 450
// 公告栏:一个uniform块,里面装一个4x4矩阵
layout(binding = 0) uniform UniformBufferObject {
    mat4 model;    // 模型矩阵:负责旋转/平移/缩放
} ubo;

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inColor;
layout(location = 0) out vec3 fragColor;

void main() {
    // 先乘矩阵(旋转),再输出。矩阵乘向量 = 把点变到新位置
    gl_Position = ubo.model * vec4(inPosition, 1.0);
    fragColor = inColor;
}
frag 着色器和例2完全一样,不用改。

3.5 C++ 代码变化

cpp
 
#include <glm/glm.hpp>                   // 数学库
#include <glm/gtc/matrix_transform.hpp>  // rotate/translate/perspective 等
#include <chrono>                        // 计时

// ---------- 顶点:一个三角形 ----------
float vertices[] = {
    // x, y, z,          r, g, b
     0.0f, -0.5f, 0.0f,  1.0f, 0.0f, 0.0f,   // 顶点:红
     0.5f,  0.5f, 0.0f,  0.0f, 1.0f, 0.0f,   // 右下:绿
    -0.5f,  0.5f, 0.0f,  0.0f, 0.0f, 1.0f,   // 左下:蓝
};

// ---------- 新增成员 ----------
VkBuffer uniformBuffer;
VkDeviceMemory uniformBufferMemory;
void* uniformBufferMapped;                 // 长期映射的指针,每帧直接写
VkDescriptorSetLayout descriptorSetLayout;
VkDescriptorPool descriptorPool;
VkDescriptorSet descriptorSet;
cpp
 
// ---------- 新增1:创建 UniformBuffer ----------
void createUniformBuffer() {
    VkDeviceSize size = sizeof(glm::mat4);   // 4x4矩阵 = 64字节
    // 建Buffer和分配内存的流程与顶点缓冲完全一样,只是usage不同:
    // VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT
    // (代码骨架同 createVertexBuffer,此处省略重复部分)
    // ...创建并分配后:
    vkMapMemory(device, uniformBufferMemory, 0, size, 0, &uniformBufferMapped);
    // ★注意:这次不 unmap!长期保持映射,每帧直接 memcpy,省事
}
cpp
 
// ---------- 新增2:描述符三件套 ----------
void createDescriptorSetLayout() {
    VkDescriptorSetLayoutBinding layoutBinding{};
    layoutBinding.binding = 0;                             // 对应GLSL的 binding=0
    layoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
    layoutBinding.descriptorCount = 1;
    layoutBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; // 哪个阶段的着色器能看:顶点

    VkDescriptorSetLayoutCreateInfo layoutInfo{};
    layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
    layoutInfo.bindingCount = 1;
    layoutInfo.pBindings = &layoutBinding;
    vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &descriptorSetLayout);
}

void createDescriptorPoolAndSet() {
    // 门牌号发放处
    VkDescriptorPoolSize poolSize{};
    poolSize.type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
    poolSize.descriptorCount = 1;
    VkDescriptorPoolCreateInfo poolInfo{};
    poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
    poolInfo.poolSizeCount = 1;
    poolInfo.pPoolSizes = &poolSize;
    poolInfo.maxSets = 1;
    vkCreateDescriptorPool(device, &poolInfo, nullptr, &descriptorPool);

    // 领门牌号
    VkDescriptorSetAllocateInfo allocInfo{};
    allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
    allocInfo.descriptorPool = descriptorPool;
    allocInfo.descriptorSetCount = 1;
    allocInfo.pSetLayouts = &descriptorSetLayout;
    vkAllocateDescriptorSets(device, &allocInfo, &descriptorSet);

    // 门牌号指向实际的 UniformBuffer
    VkDescriptorBufferInfo bufferInfo{};
    bufferInfo.buffer = uniformBuffer;
    bufferInfo.offset = 0;
    bufferInfo.range = sizeof(glm::mat4);
    VkWriteDescriptorSet write{};
    write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
    write.dstSet = descriptorSet;
    write.dstBinding = 0;
    write.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
    write.descriptorCount = 1;
    write.pBufferInfo = &bufferInfo;
    vkUpdateDescriptorSets(device, 1, &write, 0, nullptr);
}
cpp
 
// ---------- 改动:PipelineLayout 要挂上描述符布局 ----------
// createPipeline() 里:
VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutInfo.setLayoutCount = 1;                    // ★新增
pipelineLayoutInfo.pSetLayouts = &descriptorSetLayout;    // ★新增
vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, &pipelineLayout);
// 同时把 inputAssembly.topology 改成 VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST  ← 画实心三角形
cpp
 
// ---------- 改动:每帧更新矩阵 + 绑定描述符 ----------
void updateUniformBuffer() {
    // 程序启动到现在的秒数
    static auto startTime = std::chrono::high_resolution_clock::now();
    auto now = std::chrono::high_resolution_clock::now();
    float time = std::chrono::duration<float>(now - startTime).count();

    // 生成旋转矩阵:绕z轴转,每秒90度
    glm::mat4 model = glm::rotate(glm::mat4(1.0f),        // 单位矩阵(什么都不变)
                                  time * glm::radians(90.0f), // 角度(弧度)
                                  glm::vec3(0.0f, 0.0f, 1.0f)); // 绕z轴
    memcpy(uniformBufferMapped, &model, sizeof(model));   // 写进公告栏
}

void drawFrame() {
    // ...等栅栏、领画布(同例1)...
    updateUniformBuffer();                                 // ★画之前更新参数

    // recordCommandBuffer 里多一句绑定描述符:
    // vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
    //                         pipelineLayout, 0, 1, &descriptorSet, 0, nullptr);
    // vkCmdDraw(cmd, 3, 1, 0, 0);
    // ...提交、呈现(同例1)...
}

3.6 本章规律总结

  1. 动画 = 每帧改 Uniform,不改顶点。GPU 编程的黄金法则:能交给 GPU 算的,别让 CPU 改数据。
  2. 矩阵 × 顶点 = 移动顶点。旋转、平移、缩放全都统一成"乘一个矩阵",这就是 3D 图形学的数学基石。
  3. Uniform 上 GPU 的路很长:Buffer → Layout → Pool → Set → PipelineLayout → Bind。这套"描述符五部曲"以后传贴图(例9)还要原样走一遍,务必熟悉。

例4:每帧不断更新的 2D——动态顶点数据

4.1 目标

一条会"跳舞"的正弦波曲线:由 100 条小线段组成的波形,随时间左右滚动,像示波器。

4.2 本章新概念

有些场景顶点数据本身就必须每帧变(比如:心电图纸、粒子系统、CPU 物理模拟的结果)。这时要每帧从 CPU 往 GPU 顶点缓冲里写新数据。做法:顶点缓冲用 CPU 可见内存、长期映射,每帧 memcpy 覆盖,再画。
和例3的区别一句话:例3改的是"参数"(矩阵),本章改的是"原料"(顶点本身)。

4.3 中文伪代码流程

plain
 
启动:
  顶点缓冲大小 = 200个顶点(100条线段 × 2端点)× 6个float
  长期映射顶点缓冲内存
  图元类型 = LINE_LIST

每帧:
  time = 当前时间
  for i in 0..199:                        ← 在CPU上算出新波形
      x = -1.0 + i * (2.0/199)            ← 从左到右铺满屏幕
      y = 0.5 * sin(x * 10.0 + time * 3)  ← 正弦波,随时间平移
      顶点[i] = {x, y, 0, 颜色...}
  memcpy 全部顶点 → GPU顶点缓冲           ← ★本章核心动作
  领画布、录命令、vkCmdDraw(200个顶点)、提交、呈现

4.4 着色器

和例2完全相同(顶点带颜色,直线传递),不用改。

4.5 C++ 代码变化

cpp
 
#include <cmath>

const int POINT_COUNT = 200;              // 200个顶点 = 100条线段

// ---------- 成员:顶点缓冲长期映射 ----------
void* vertexBufferMapped;                 // createVertexBuffer 里 map 后不 unmap
float cpuVertices[POINT_COUNT * 6];       // CPU端的草稿纸

// ---------- 新增:每帧在CPU上算波形 ----------
void updateVertices() {
    static auto start = std::chrono::high_resolution_clock::now();
    float t = std::chrono::duration<float>(
        std::chrono::high_resolution_clock::now() - start).count();

    for (int i = 0; i < POINT_COUNT; i++) {
        float x = -1.0f + i * (2.0f / (POINT_COUNT - 1)); // x铺满[-1,1]
        float y = 0.5f * sinf(x * 10.0f + t * 3.0f);      // 会滚动的正弦波
        cpuVertices[i * 6 + 0] = x;
        cpuVertices[i * 6 + 1] = y;
        cpuVertices[i * 6 + 2] = 0.0f;
        cpuVertices[i * 6 + 3] = 0.2f;      // r:固定淡青色
        cpuVertices[i * 6 + 4] = 0.8f;      // g
        cpuVertices[i * 6 + 5] = 1.0f;      // b
    }
    // 一次性把整张草稿纸抄进 GPU 顶点缓冲
    memcpy(vertexBufferMapped, cpuVertices, sizeof(cpuVertices));
}

void drawFrame() {
    // ...等栅栏、领画布...
    updateVertices();                       // ★录命令之前先更新原料
    // 录命令:vkCmdDraw(cmd, POINT_COUNT, 1, 0, 0);
    // ...提交、呈现...
}
提示:因为顶点缓冲是 CPU 可见内存且每帧覆盖,要小心"CPU 正在写、GPU 还在读上一帧"的冲突。教学中用 vkWaitForFences 保证上一帧画完才写,已经够安全。工程上的做法是开 2~3 份缓冲轮转(叫"环形缓冲"),例10 会用到这个思想。

4.6 本章规律总结

  1. 现在两条动画路线都见过了:改参数(Uniform,例3) vs 改数据(顶点缓冲,例4)。能用前者就别用后者——CPU 写 200 个点还好,写 200 万个点就会成为瓶颈。
  2. "长期映射 + memcpy"是 CPU→GPU 传数据的最朴素方式,以后加载模型、更新粒子都是这个套路。

例5:简单 3D——旋转的彩色立方体

5.1 目标

屏幕中央一个旋转的立方体,能明显看出"立体感"。这是从 2D 跨进 3D 的一章。

5.2 本章新概念(3D 的三大支柱)

支柱一:MVP 矩阵——"相机系统"

3D 的顶点在"世界"里,屏幕是 2D 的。把 3D 世界拍到 2D 屏幕上,要经过三次坐标变换,每次都是乘一个矩阵:
plain
 
模型坐标 --乘M(Model矩阵)--> 世界坐标 --乘V(View矩阵)--> 相机坐标 --乘P(Projection矩阵)--> 屏幕坐标
用拍照打比方:
  • M(模型矩阵):摆姿势。把物体搬到世界的某个位置、旋转某个角度(例3 已经用过)。
  • V(视图矩阵):架相机。相机站哪、朝哪看。用 glm::lookAt(相机位置, 看的目标, 上方向) 生成。
  • P(投影矩阵):镜头成像。把 3D 空间"压扁"成 2D,并产生近大远小的透视效果。用 glm::perspective(视野角, 宽高比, 近裁面, 远裁面) 生成。
顶点着色器里一行搞定:gl_Position = P × V × M × 顶点。矩阵乘法从右往左生效:先摆姿势、再架相机、最后成像。

支柱二:索引缓冲(Index Buffer)——"点不重复存"

立方体只有 8 个顶点,但有 12 个三角形(6 个面 × 2),如果按例2~例4 的方式存三角形顶点,要存 36 个顶点——同一个角点被重复存了好几次。
索引缓冲的思路:顶点只存 8 个,另存一张"拼图说明书"(整数数组),说明书画三角形时只写顶点编号:
plain
 
顶点数组:  [v0, v1, v2, ..., v7]           ← 8个
索引数组:  [0,1,2, 2,3,0, 4,5,6, ...]      ← 36个整数,每3个拼一个三角形
画命令也从 vkCmdDraw 换成 vkCmdDrawIndexed。真实模型动辄几十万顶点,索引缓冲能省一半以上内存。

支柱三:深度缓冲(Depth Buffer)——"谁挡住谁"

立方体旋转时,背面必须被正面挡住。GPU 的做法:除了颜色画布,再配一张深度画布,记录每个像素"已经画上去的物体离相机多远"。画新像素时先比深度:
plain
 
新像素的深度 < 深度画布上的值  →  新物体更近,画上去,并更新深度值
否则                          →  被挡住了,丢弃
Vulkan 里要:创建一张 Depth 格式的 Image + ImageView → RenderPass 增加一个 depth 附件 → 管线开启 depthTest → 清屏时深度清成 1.0(最远)。

5.3 中文伪代码流程

plain
 
启动(在例3基础上改):
  顶点数据 = 立方体8个顶点(每个:坐标xyz + 颜色rgb)
  新增索引缓冲 = 36个uint16_t,描述12个三角形
  UniformBuffer 扩容:装 M、V、P 三个矩阵(64×3=192字节)
  创建深度图像 + 深度ImageView
  RenderPass:附件从1个变2个(颜色 + 深度)
  Pipeline:开启 depthTestEnable,图元 = TRIANGLE_LIST
  Framebuffer:每个挂2个附件(颜色视图 + 深度视图)

每帧:
  M = rotate(时间)                      ← 立方体自转
  V = lookAt(相机在(2,2,2), 看向原点, 上是y轴)
  P = perspective(45度, 宽/高, 0.1, 10)
  memcpy 三个矩阵 → UniformBuffer
  录命令:绑定顶点缓冲 + ★绑定索引缓冲 + 绑定描述符
  vkCmdDrawIndexed(36个索引)            ← ★新命令
  提交、呈现

5.4 着色器变化

shader.vert——公告栏变成三个矩阵:
glsl
 
#version 450
layout(binding = 0) uniform UniformBufferObject {
    mat4 model;   // M:摆姿势
    mat4 view;    // V:架相机
    mat4 proj;    // P:镜头成像(近大远小)
} ubo;

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inColor;
layout(location = 0) out vec3 fragColor;

void main() {
    // MVP连乘:从右往左读——先摆姿势,再架相机,再成像
    gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 1.0);
    fragColor = inColor;
}
frag 不变。

5.5 C++ 代码变化

cpp
 
// ---------- 立方体数据 ----------
// 8个顶点,每个面一种颜色
float vertices[] = {
    // x, y, z             r, g, b
    -0.5f,-0.5f,-0.5f,   1,0,0,   // v0 红
     0.5f,-0.5f,-0.5f,   0,1,0,   // v1 绿
     0.5f, 0.5f,-0.5f,   0,0,1,   // v2 蓝
    -0.5f, 0.5f,-0.5f,   1,1,0,   // v3 黄
    -0.5f,-0.5f, 0.5f,   1,0,1,   // v4 紫
     0.5f,-0.5f, 0.5f,   0,1,1,   // v5 青
     0.5f, 0.5f, 0.5f,   1,1,1,   // v6 白
    -0.5f, 0.5f, 0.5f,   1,0.5f,0 // v7 橙
};

// 索引:12个三角形拼出6个面(每个面2个三角形)
uint16_t indices[] = {
    0,1,2, 2,3,0,   // 后面
    4,6,5, 4,7,6,   // 前面
    4,5,1, 4,1,0,   // 底面
    7,3,2, 7,2,6,   // 顶面
    4,0,3, 4,3,7,   // 左面
    1,5,6, 1,6,2    // 右面
};
const uint32_t INDEX_COUNT = 36;
cpp
 
// ---------- 索引缓冲:创建方法和顶点缓冲一模一样 ----------
void createIndexBuffer() {
    VkDeviceSize size = sizeof(indices);
    // usage 换成 VK_BUFFER_USAGE_INDEX_BUFFER_BIT,其余照抄顶点缓冲流程
    // 创建 → 分配内存 → map → memcpy(indices) → unmap
    // ...(骨架略,成员:VkBuffer indexBuffer; VkDeviceMemory indexBufferMemory;)
}
cpp
 
// ---------- Uniform 数据结构:三个矩阵 ----------
struct UniformBufferObject {
    glm::mat4 model;
    glm::mat4 view;
    glm::mat4 proj;
};
// createUniformBuffer 的 size 改为 sizeof(UniformBufferObject)

void updateUniformBuffer() {
    static auto start = std::chrono::high_resolution_clock::now();
    float t = std::chrono::duration<float>(
        std::chrono::high_resolution_clock::now() - start).count();

    UniformBufferObject ubo{};
    // M:绕y轴自转,每秒45度
    ubo.model = glm::rotate(glm::mat4(1.0f), t * glm::radians(45.0f),
                            glm::vec3(0.0f, 1.0f, 0.0f));
    // V:相机站在(2,2,2),看向原点,头顶方向是+y
    ubo.view = glm::lookAt(glm::vec3(2.0f, 2.0f, 2.0f),
                           glm::vec3(0.0f, 0.0f, 0.0f),
                           glm::vec3(0.0f, 1.0f, 0.0f));
    // P:45度视野、按窗口宽高比、能看见0.1到10之间的物体
    ubo.proj = glm::perspective(glm::radians(45.0f),
                                (float)WIDTH / (float)HEIGHT,
                                0.1f, 10.0f);
    ubo.proj[1][1] *= -1;   // ★GLM为OpenGL设计,y轴方向和Vulkan相反,翻转一下

    memcpy(uniformBufferMapped, &ubo, sizeof(ubo));
}
cpp
 
// ---------- 深度缓冲 ----------
VkImage depthImage;
VkDeviceMemory depthImageMemory;
VkImageView depthImageView;

void createDepthResources() {
    VkFormat depthFormat = VK_FORMAT_D32_SFLOAT;   // 每像素存一个float深度

    // 1. 建图像对象
    VkImageCreateInfo imageInfo{};
    imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
    imageInfo.imageType = VK_IMAGE_TYPE_2D;
    imageInfo.extent = { swapchainExtent.width, swapchainExtent.height, 1 };
    imageInfo.mipLevels = 1;
    imageInfo.arrayLayers = 1;
    imageInfo.format = depthFormat;
    imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
    imageInfo.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT; // 用途:深度附件
    imageInfo.samples = VK_SAMPLE_COUNT_1_BIT;
    vkCreateImage(device, &imageInfo, nullptr, &depthImage);
    // 2. 分配内存并绑定(流程同Buffer,用 vkGetImageMemoryRequirements + vkAllocateMemory + vkBindImageMemory)
    // 3. 建ImageView(aspectMask 改为 VK_IMAGE_ASPECT_DEPTH_BIT)
    // ...(骨架同 createImageViews,略)
}
cpp
 
// ---------- RenderPass:两个附件 ----------
void createRenderPass() {
    VkAttachmentDescription colorAttachment{};   // 同例1
    // ...(format/loadOp=Clear/storeOp=Store/finalLayout=PRESENT_SRC_KHR,同例1)

    VkAttachmentDescription depthAttachment{};
    depthAttachment.format = VK_FORMAT_D32_SFLOAT;
    depthAttachment.samples = VK_SAMPLE_COUNT_1_BIT;
    depthAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;      // 每帧把深度清成最远
    depthAttachment.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;// 画完不需要保留
    depthAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
    depthAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
    depthAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
    depthAttachment.finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;

    VkAttachmentReference colorRef{ 0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL };
    VkAttachmentReference depthRef{ 1, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL };

    VkSubpassDescription subpass{};
    subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
    subpass.colorAttachmentCount = 1;
    subpass.pColorAttachments = &colorRef;
    subpass.pDepthStencilAttachment = &depthRef;    // ★挂上深度

    VkAttachmentDescription attachments[] = { colorAttachment, depthAttachment };
    VkRenderPassCreateInfo renderPassInfo{};
    renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
    renderPassInfo.attachmentCount = 2;             // ★ 1→2
    renderPassInfo.pAttachments = attachments;
    renderPassInfo.subpassCount = 1;
    renderPassInfo.pSubpasses = &subpass;
    vkCreateRenderPass(device, &renderPassInfo, nullptr, &renderPass);
}
cpp
 
// ---------- Pipeline:开启深度测试 ----------
// createPipeline() 里增加:
VkPipelineDepthStencilStateCreateInfo depthStencil{};
depthStencil.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
depthStencil.depthTestEnable = VK_TRUE;          // 开启深度比较
depthStencil.depthWriteEnable = VK_TRUE;         // 通过的像素要更新深度值
depthStencil.depthCompareOp = VK_COMPARE_OP_LESS;// 更近的才画
// 并把它挂进 pipelineInfo:
// pipelineInfo.pDepthStencilState = &depthStencil;
// 图元类型确认是 VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST
cpp
 
// ---------- Framebuffer:每个挂2张视图 ----------
// createFramebuffers 循环内:
VkImageView attachments[] = { swapchainImageViews[i], depthImageView };
// fbInfo.attachmentCount = 2; fbInfo.pAttachments = attachments;

// ---------- 录命令:换 DrawIndexed ----------
void recordCommandBuffer(VkCommandBuffer cmd, uint32_t imageIndex) {
    // ...
    // 清屏值也要给两个:颜色清黑,深度清1.0(最远)
    VkClearValue clearValues[2];
    clearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}};
    clearValues[1].depthStencil = {1.0f, 0};
    // renderPassInfo.clearValueCount = 2; pClearValues = clearValues;

    vkCmdBeginRenderPass(cmd, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE);
    vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
    VkDeviceSize offsets[] = {0};
    vkCmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer, offsets);
    vkCmdBindIndexBuffer(cmd, indexBuffer, 0, VK_INDEX_TYPE_UINT16);   // ★绑索引
    vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
                            pipelineLayout, 0, 1, &descriptorSet, 0, nullptr);
    vkCmdDrawIndexed(cmd, INDEX_COUNT, 1, 0, 0, 0);                    // ★按索引画
    vkCmdEndRenderPass(cmd);
    // ...
}

5.6 本章规律总结

  1. 3D = 2D + 三样东西:MVP 矩阵(立体感)、索引缓冲(省内存)、深度缓冲(遮挡关系)。加上这三样,立方体就"立"起来了。
  2. glm::perspective 之后记住 proj[1][1] *= -1(GLM 与 Vulkan 坐标系差异),这是每个 Vulkan 新手必踩的坑。
  3. 至此你已经掌握了现代 3D 渲染的完整最小闭环。后面五个例子全是"在这个闭环上加东西"。

例6:多个 3D 物体——一百个立方体

6.1 目标

同一个立方体画 100 份,排成 10×10 的方阵,各自绕自己的轴旋转。

6.2 本章新概念

关键问题:100 个物体的 M 矩阵各不相同,怎么传?
两种思路:
  1. 每个物体一个 DescriptorSet——太浪费;
  2. 一个大的 UniformBuffer 数组:开一块能装 100 个矩阵的缓冲,每次 vkCmdDrawIndexed 前用 动态偏移(Dynamic Offset) 指到第 i 个矩阵。本章用这个(把 descriptorType 换成 VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC)。
这也顺带回答了"什么时候重新录命令":每帧循环 100 次,每次设置动态偏移 → 画一个立方体。一条命令缓冲里可以画任意多次,这就是"场景"的雏形。

6.3 中文伪代码流程

plain
 
启动:
  顶点/索引缓冲:还是那一个立方体(★数据只存一份!)
  UniformBuffer:开 100 × 64字节 的大缓冲(只放M矩阵,V和P共用一份放另一个uniform)
  描述符类型:UNIFORM_BUFFER_DYNAMIC

每帧:
  更新 V、P(相机固定,其实可以只在启动时算一次)
  for i in 0..99:
      M[i] = 平移到方阵位置(i%10, 0, i/10) × 自转(时间 + i的相位差)
  memcpy 100个M → 大UniformBuffer
  录命令:
      for i in 0..99:
          绑定描述符,动态偏移 = i × 对齐后的大小   ← 指到第i个矩阵
          vkCmdDrawIndexed(36)

6.4 着色器变化

glsl
 
#version 450
// 共用参数:相机固定,所有物体一样
layout(binding = 0) uniform CommonUBO {
    mat4 view;
    mat4 proj;
} common;
// 每个物体自己的M矩阵:动态uniform
layout(binding = 1) uniform ModelUBO {
    mat4 model;
} obj;

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inColor;
layout(location = 0) out vec3 fragColor;

void main() {
    gl_Position = common.proj * common.view * obj.model * vec4(inPosition, 1.0);
    fragColor = inColor;
}

6.5 C++ 代码变化

cpp
 
const int OBJECT_COUNT = 100;

// ---------- 描述符布局:两个绑定点 ----------
void createDescriptorSetLayout() {
    VkDescriptorSetLayoutBinding bindings[2]{};
    bindings[0].binding = 0;                                  // 相机矩阵
    bindings[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
    bindings[0].descriptorCount = 1;
    bindings[0].stageFlags = VK_SHADER_STAGE_VERTEX_BIT;
    bindings[1].binding = 1;                                  // 每物体的M矩阵
    bindings[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC; // ★动态
    bindings[1].descriptorCount = 1;
    bindings[1].stageFlags = VK_SHADER_STAGE_VERTEX_BIT;

    VkDescriptorSetLayoutCreateInfo layoutInfo{};
    layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
    layoutInfo.bindingCount = 2;
    layoutInfo.pBindings = bindings;
    vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &descriptorSetLayout);
}

// ★动态uniform要求"对齐":每个矩阵的起始地址必须是 minUniformBufferOffsetAlignment
// (通常256)的整数倍。查询并对齐:
size_t dynamicAlignment;   // 启动时算好
void initDynamicAlignment() {
    VkPhysicalDeviceProperties props;
    vkGetPhysicalDeviceProperties(physicalDevice, &props);
    size_t minAlign = props.limits.minUniformBufferOffsetAlignment;
    dynamicAlignment = (sizeof(glm::mat4) + minAlign - 1) & ~(minAlign - 1);
    // 例如 64 对齐到 256 → dynamicAlignment = 256
}
cpp
 
// ---------- 每帧:更新100个矩阵 ----------
void updateUniformBuffer() {
    static auto start = std::chrono::high_resolution_clock::now();
    float t = std::chrono::duration<float>(
        std::chrono::high_resolution_clock::now() - start).count();

    // 相机(所有物体共用)
    struct { glm::mat4 view; glm::mat4 proj; } common;
    common.view = glm::lookAt(glm::vec3(9.0f, 12.0f, 18.0f),
                              glm::vec3(4.5f, 0.0f, 4.5f),
                              glm::vec3(0.0f, 1.0f, 0.0f));
    common.proj = glm::perspective(glm::radians(45.0f),
                                   (float)WIDTH / HEIGHT, 0.1f, 100.0f);
    common.proj[1][1] *= -1;
    memcpy(commonUniformMapped, &common, sizeof(common));

    // 100个模型矩阵,写进按对齐间隔排布的大缓冲
    for (int i = 0; i < OBJECT_COUNT; i++) {
        glm::mat4 model = glm::mat4(1.0f);
        model = glm::translate(model, glm::vec3(i % 10, 0.0f, i / 10)); // 方阵位置
        model = glm::rotate(model, t * glm::radians(45.0f) + i * 0.3f,  // 各自自转
                            glm::vec3(0.0f, 1.0f, 0.0f));
        // ★第i个矩阵写到 mapped + i*对齐间隔 的位置
        memcpy((char*)modelUniformMapped + i * dynamicAlignment,
               &model, sizeof(model));
    }
}

// ---------- 录命令:循环画100次 ----------
void recordCommandBuffer(VkCommandBuffer cmd, uint32_t imageIndex) {
    // ...begin renderpass、绑管线、绑顶点/索引缓冲(同例5)...
    for (int i = 0; i < OBJECT_COUNT; i++) {
        uint32_t dynamicOffset = i * (uint32_t)dynamicAlignment;  // 指到第i个M
        vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
                                pipelineLayout, 0, 1, &descriptorSet,
                                1, &dynamicOffset);               // ★传动态偏移
        vkCmdDrawIndexed(cmd, INDEX_COUNT, 1, 0, 0, 0);
    }
    // ...end renderpass...
}

6.6 本章规律总结

  1. 同一份模型数据 + 不同的 M 矩阵 = 场景里的一群物体。顶点缓冲永远只有一份。
  2. 动态 Uniform是"一批同类物体"的标准做法;理解了对齐(alignment)要求就没什么神秘的。
  3. 一条命令缓冲里画 N 次非常便宜。DrawCall(一次绘制命令)数量以后会成为你关心的性能指标。

例7:导入外部 3D 模型(OBJ 文件)

7.1 目标

不再手写顶点,而是从硬盘加载一个 .obj 模型文件(比如一个茶壶、一只兔子),显示并旋转它。

7.2 本章新概念

OBJ 文件是什么

美术用建模软件(Blender 等)做好模型,导出成文件。.obj 是最简单的一种——纯文本,打开就能看到:
plain
 
v  1.0 0.0 0.5        ← v = 顶点坐标(可能有几十万个)
vn 0.0 1.0 0.0        ← vn = 法线(面的朝向,例10光照要用)
vt 0.5 0.3            ← vt = 贴图坐标(例9要用)
f  1/1/1 2/2/2 3/3/3  ← f = 面:哪几个顶点拼成三角形(编号/贴图编号/法线编号)
加载模型 = 解析这些文本行,填进顶点数组和索引数组,之后就和例5 完全一样了。我们用现成的小库 tinyobjloader(单头文件,不用编译)来解析。

暂存缓冲(Staging Buffer)——正式工程的传数据姿势

前面为了省事,顶点缓冲用的是"CPU 可见内存"。但 GPU 读得最快的内存是 GPU 本地显存(CPU 看不见、不能 memcpy)。正式做法分两步:
plain
 
CPU内存 --memcpy--> 暂存缓冲(CPU可见) --vkCmdCopyBuffer(GPU内部搬运)--> 顶点缓冲(GPU本地显存)
类比:你家(CPU)和工厂内部仓库(GPU 显存)之间没有直达门,要先放到"中转站"(暂存缓冲),工厂自己的叉车再搬进去。本章顺便把这个正确姿势学了。

7.3 中文伪代码流程

plain
 
启动:
  loadModel("bunny.obj"):
      用tinyobjloader解析文件
      for 每个面f的每个顶点引用:
          取出坐标、法线 → 填进 vertices 数组
          填进 indices 数组
      (真实模型顶点很多:几万~几百万)
  createBufferViaStaging(vertices):       ← 新姿势
      建暂存缓冲(CPU可见) → memcpy顶点数据
      建顶点缓冲(usage加TRANSFER_DST,GPU本地内存)
      录一条 vkCmdCopyBuffer 命令 → 提交 → 等GPU搬完
      销毁暂存缓冲
  索引缓冲同样走一遍暂存流程
  其余(MVP、深度、描述符)和例5完全一样

每帧:和例5完全一样(M矩阵旋转模型)
划重点:Vulkan 部分相对例5 几乎没变,本章 80% 的新内容是"文件解析"和"暂存搬运"这两个数据工程问题。这就是真实工作的样子——图形 API 学一次,大量时间在处理数据。

7.4 着色器

和例5 相同(坐标+颜色)。如果想用模型自带的法线做简单明暗,可以先把法线当颜色输出(fragColor = normal * 0.5 + 0.5),直观看到模型表面的朝向,这也是检查模型加载是否正确的常用手段。

7.5 C++ 代码

cpp
 
// tinyobjloader:单头文件库,去GitHub下载 tiny_obj_loader.h 放到项目目录
#define TINYOBJLOADER_IMPLEMENTATION
#include "tiny_obj_loader.h"
#include <unordered_map>

// ---------- 顶点结构体:从裸数组升级为结构体(工程做法,更清晰) ----------
struct Vertex {
    glm::vec3 pos;      // 坐标
    glm::vec3 normal;   // 法线(例10光照用,本章先存着)
    glm::vec3 color;    // 颜色(OBJ通常不带颜色,给个默认色)
};

// 顶点和索引容器:大小要在加载时才知道,所以用vector
std::vector<Vertex> vertices;
std::vector<uint32_t> indices;   // 真实模型顶点数可能超65535,用32位索引

// ---------- 加载模型 ----------
void loadModel(const std::string& path) {
    tinyobj::attrib_t attrib;                    // 所有v/vn/vt的原始数组
    std::vector<tinyobj::shape_t> shapes;        // 模型里的各个"零件"
    std::vector<tinyobj::material_t> materials;  // 材质(本章不用)
    std::string warn, err;

    if (!tinyobj::LoadObj(&attrib, &shapes, &materials, &warn, &err, path.c_str()))
        throw std::runtime_error("模型加载失败: " + err);

    // 用哈希表去重:相同坐标的顶点只存一份(节省显存)
    std::unordered_map<std::string, uint32_t> uniqueVertices;

    for (const auto& shape : shapes) {
        for (const auto& index : shape.mesh.indices) {
            Vertex v{};
            // 顶点坐标:index.vertex_index 是 v 列表里的编号
            v.pos = {
                attrib.vertices[3 * index.vertex_index + 0],
                attrib.vertices[3 * index.vertex_index + 1],
                attrib.vertices[3 * index.vertex_index + 2]
            };
            // 法线(如果文件里有的话)
            if (index.normal_index >= 0) {
                v.normal = {
                    attrib.normals[3 * index.normal_index + 0],
                    attrib.normals[3 * index.normal_index + 1],
                    attrib.normals[3 * index.normal_index + 2]
                };
            }
            v.color = {0.7f, 0.7f, 0.8f};   // 统一淡蓝灰色

            // 用坐标字符串做key来去重
            std::string key = std::to_string(v.pos.x) + "_" +
                              std::to_string(v.pos.y) + "_" +
                              std::to_string(v.pos.z);
            if (uniqueVertices.count(key) == 0) {
                uniqueVertices[key] = (uint32_t)vertices.size();
                vertices.push_back(v);                  // 新顶点,存进数组
            }
            indices.push_back(uniqueVertices[key]);     // 索引指向顶点编号
        }
    }
    std::cout << "模型加载完成: " << vertices.size() << " 顶点, "
              << indices.size() / 3 << " 个三角形" << std::endl;
}
cpp
 
// ---------- 通用"经暂存缓冲创建GPU本地Buffer"工具函数 ----------
// 这是工程标准写法,顶点/索引/任何数据通用
void createBufferWithStaging(const void* srcData, VkDeviceSize size,
                             VkBufferUsageFlags usage,   // 用途:顶点 or 索引
                             VkBuffer& outBuffer, VkDeviceMemory& outMemory) {
    // 1. 建"中转站":CPU可见的暂存缓冲
    VkBuffer stagingBuffer;
    VkDeviceMemory stagingMemory;
    createRawBuffer(size, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,   // 用途:搬运的源头
                    /*CPU可见*/ true, stagingBuffer, stagingMemory);
    // 2. 数据搬进中转站
    void* data;
    vkMapMemory(device, stagingMemory, 0, size, 0, &data);
    memcpy(data, srcData, size);
    vkUnmapMemory(device, stagingMemory);

    // 3. 建"内部仓库":GPU本地显存
    createRawBuffer(size, usage | VK_BUFFER_USAGE_TRANSFER_DST_BIT, // 用途+搬运目的地
                    /*CPU可见*/ false, outBuffer, outMemory);

    // 4. 让GPU自己的"叉车"把数据从中转站搬进内部仓库
    VkCommandBufferAllocateInfo allocInfo{};
    allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
    allocInfo.commandPool = commandPool;
    allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
    allocInfo.commandBufferCount = 1;
    VkCommandBuffer copyCmd;
    vkAllocateCommandBuffers(device, &allocInfo, &copyCmd);

    VkCommandBufferBeginInfo beginInfo{};
    beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
    beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; // 用完即弃的命令
    vkBeginCommandBuffer(copyCmd, &beginInfo);
    VkBufferCopy copyRegion{};
    copyRegion.size = size;
    vkCmdCopyBuffer(copyCmd, stagingBuffer, outBuffer, 1, &copyRegion); // ★搬运命令
    vkEndCommandBuffer(copyCmd);

    VkSubmitInfo submitInfo{};
    submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
    submitInfo.commandBufferCount = 1;
    submitInfo.pCommandBuffers = &copyCmd;
    vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE);
    vkQueueWaitIdle(graphicsQueue);          // 等叉车搬完(简单起见直接等)

    // 5. 中转站用完就拆
    vkFreeCommandBuffers(device, commandPool, 1, &copyCmd);
    vkDestroyBuffer(device, stagingBuffer, nullptr);
    vkFreeMemory(device, stagingMemory, nullptr);
}

// 调用处(initVulkan 里):
//   loadModel("bunny.obj");
//   createBufferWithStaging(vertices.data(), vertices.size() * sizeof(Vertex),
//                           VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, vertexBuffer, vertexBufferMemory);
//   createBufferWithStaging(indices.data(), indices.size() * sizeof(uint32_t),
//                           VK_BUFFER_USAGE_INDEX_BUFFER_BIT, indexBuffer, indexBufferMemory);
cpp
 
// ---------- 顶点格式说明书:跟着 Vertex 结构体走 ----------
// createPipeline() 里:
VkVertexInputBindingDescription binding{};
binding.binding = 0;
binding.stride = sizeof(Vertex);                    // 一个Vertex的大小
binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;

VkVertexInputAttributeDescription attributes[3]{};
attributes[0] = { 0, 0, VK_FORMAT_R32G32B32_SFLOAT, offsetof(Vertex, pos) };    // location0 坐标
attributes[1] = { 1, 0, VK_FORMAT_R32G32B32_SFLOAT, offsetof(Vertex, normal) }; // location1 法线
attributes[2] = { 2, 0, VK_FORMAT_R32G32B32_SFLOAT, offsetof(Vertex, color) };  // location2 颜色
// vertexInput.vertexAttributeDescriptionCount = 3;
// offsetof 自动算出成员在结构体里的字节偏移,不用手算,防错
cpp
 
// ---------- 画图:索引数量来自加载结果 ----------
// vkCmdDrawIndexed(cmd, (uint32_t)indices.size(), 1, 0, 0, 0);
// 索引类型注意换成 VK_INDEX_TYPE_UINT32:
// vkCmdBindIndexBuffer(cmd, indexBuffer, 0, VK_INDEX_TYPE_UINT32);

// 相机可能要调远:模型大小不一,常见做法是先算模型的包围盒,
// 把相机放在 包围盒中心 + 2倍半径 的距离上。教学时手动试几个数即可。

7.6 本章规律总结

  1. 加载模型 = 文本解析 + 填数组,填完之后的渲染流程和手写立方体一字不差。
  2. 暂存缓冲两步走是正式工程的标准传数据姿势:CPU 可见的中转站 → GPU 本地显存。例9 传贴图图片时还要再用一次(那时搬的是 Image 不是 Buffer)。
  3. offsetof + 结构体顶点,是顶点格式说明书的工程写法,以后所有例子都用它。

例8:3D 动画——让模型动起来(关键帧与插值)

8.1 目标

加载的模型不再是死物:让它一边旋转一边上下"漂浮",并沿一条轨道移动。进一步,理解"骨骼动画"的基本思想。

8.2 本章新概念

动画的三种层次(由简到难)

  1. 变换动画(本章):整个模型的 M 矩阵随时间变化(移动/旋转/缩放组合)。适合:车辆、门、机械臂整体运动。
  2. 顶点动画:顶点坐标本身随时间变化(例4 的 3D 版)。适合:波浪、软体。
  3. 骨骼动画(Skeletal Animation):模型绑定一套"骨架"(几十根骨头),动画只记录骨头的旋转,顶点跟着"自己所属的骨头"走。适合:人物、动物。本章讲思想,不实现(需要 glTF 格式 + 蒙皮矩阵,是进阶课题)。

关键帧思想

动画文件不存每一帧,只存关键时刻的姿势(关键帧),中间用数学插值补出来:
plain
 
0.0秒:位置 = (0, 0, 0)
1.0秒:位置 = (2, 1, 0)
0.5秒时:位置 = 两头的平均值 = (1, 0.5, 0)   ← 线性插值 lerp
旋转的插值用四元数(Quaternion)和 slerp(球面线性插值),因为直接平均角度会出错。GLM 都内置了。

8.3 中文伪代码流程

plain
 
启动:和例7完全一样

每帧(核心是"用时间组装M矩阵"):
  t = 当前时间(秒)
  // 轨道运动:绕原点转圈,半径5
  位置 = (5*cos(t), 上下漂浮, 5*sin(t))
  上下漂浮 = 0.5 * sin(t * 2)            ← 不同频率叠加,运动更"活"
  朝向 = -t(始终面朝运动方向)

  M = 平移(位置) × 旋转(朝向) × 缩放(0.5)   ← 注意乘法顺序:先缩放、再旋转、后平移
  memcpy M → Uniform
  画模型

  附加演示——简单关键帧系统:
    关键帧数组 = [(0秒, 位置A), (2秒, 位置B), (4秒, 位置C), (6秒, 回到A)]
    当前时间 → 找到落在哪两个关键帧之间 → 算出进度比例α(0~1)
    位置 = lerp(前帧位置, 后帧位置, α)
矩阵乘法顺序口诀:M = T × R × S(平移×旋转×缩放)。从右往左生效:先缩放成合适大小,再原地转好朝向,最后搬到世界位置。顺序错了会出现"绕远点公转"之类的灵异现象。

8.4 着色器

和例5/例7 完全相同,不用改。动画的全部魔法都在 CPU 端组装 M 矩阵。

8.5 C++ 代码

cpp
 
#include <glm/gtc/quaternion.hpp>   // 四元数(旋转插值用)

// ---------- 方式一:程序化动画(数学公式直接算) ----------
glm::mat4 computeAnimatedModel(float t) {
    // 轨道运动 + 上下漂浮
    glm::vec3 position = {
        5.0f * cosf(t),          // x:绕圈
        0.5f * sinf(t * 2.0f),   // y:上下漂浮(频率不同,轨迹不重复)
        5.0f * sinf(t)           // z:绕圈
    };
    float heading = -t;          // 朝向跟随运动方向

    glm::mat4 model = glm::mat4(1.0f);
    model = glm::translate(model, position);                        // 第3步:搬到位置
    model = glm::rotate(model, heading, glm::vec3(0, 1, 0));        // 第2步:转向
    model = glm::scale(model, glm::vec3(0.5f));                     // 第1步:缩小
    return model;   // 实际生效顺序:缩放→旋转→平移
}
cpp
 
// ---------- 方式二:关键帧动画(真实动画系统的简化版) ----------
struct Keyframe {
    float time;          // 关键时刻(秒)
    glm::vec3 position;  // 这一刻的位置
    float yaw;           // 这一刻的朝向(简化:只绕y轴)
};

class SimpleAnimator {
public:
    std::vector<Keyframe> keys;
    float duration = 6.0f;   // 整个动画循环总长

    // 输入时间,输出这一瞬间的位置和朝向
    void sample(float t, glm::vec3& outPos, float& outYaw) {
        t = fmodf(t, duration);   // 循环播放:时间对总长取余

        // 找到 t 落在哪两个关键帧之间
        for (size_t i = 0; i + 1 < keys.size(); i++) {
            if (t >= keys[i].time && t <= keys[i + 1].time) {
                float alpha = (t - keys[i].time) / (keys[i + 1].time - keys[i].time);
                // ★线性插值:alpha=0完全在前帧,alpha=1完全在后帧,中间按比例混合
                outPos = glm::mix(keys[i].position, keys[i + 1].position, alpha);
                outYaw = glm::mix(keys[i].yaw, keys[i + 1].yaw, alpha);
                return;
            }
        }
        outPos = keys.back().position;
        outYaw = keys.back().yaw;
    }
};

SimpleAnimator animator;
void setupAnimation() {
    animator.keys = {
        { 0.0f, { 0, 0, 0}, 0.0f },
        { 2.0f, { 3, 1, 0}, glm::radians(90.0f) },
        { 4.0f, { 3, 1, 3}, glm::radians(180.0f) },
        { 6.0f, { 0, 0, 0}, glm::radians(360.0f) },
    };
}

void updateUniformBuffer() {
    static auto start = std::chrono::high_resolution_clock::now();
    float t = std::chrono::duration<float>(
        std::chrono::high_resolution_clock::now() - start).count();

    glm::vec3 pos; float yaw;
    animator.sample(t, pos, yaw);              // 关键帧插值出当前姿势

    glm::mat4 model = glm::mat4(1.0f);
    model = glm::translate(model, pos);
    model = glm::rotate(model, yaw, glm::vec3(0, 1, 0));
    model = glm::scale(model, glm::vec3(0.5f));
    // V、P 同例5,memcpy 进 UniformBuffer(略)
}

骨骼动画是怎么回事(只讲思想)

plain
 
模型每个顶点额外记录:我跟着哪几根骨头走?各听多少?(权重,比如 60%听脊椎, 40%听肋骨)
动画文件记录:每根骨头在每个关键帧的旋转
每帧CPU/GPU做的事:
  1. 插值出每根骨头此刻的变换矩阵(和上面SimpleAnimator一模一样的思路,只是有几十根)
  2. 每个顶点的最终位置 = Σ(骨头的矩阵 × 顶点) × 权重    ← 加权和
效果:骨头一动,顶点跟着走 → 手臂抬起来了
真实工程里骨骼动画用 glTF 格式(模型+骨架+动画全在一个文件里),用 tinygltf 库加载,顶点着色器里加"蒙皮矩阵"计算。等你把本文十个例子吃透,这就是下一站。

8.6 本章规律总结

  1. 动画 = 时间 → 矩阵。不管多复杂的动画,到渲染层面都只是"M 矩阵(或骨头矩阵)每帧不同"。
  2. 插值(lerp/mix)是动画的数学心脏:知道两端,中间按比例混合。
  3. 矩阵顺序 T×R×S,写错顺序是 3D 新手第二常见的 bug(第一是忘翻转 proj 的 y)。

例9:纹理贴图——给模型穿"照片外衣"

9.1 目标

给立方体贴上一张图片(比如木箱子的照片),让 3D 物体从"彩色积木"变成"真实物体"。

9.2 本章新概念

纹理(Texture)与 UV 坐标

  • 纹理:就是一张普通图片(jpg/png),上传到 GPU 显存里。
  • UV 坐标:每个顶点多带两个小数 (u, v),范围 0~1,表示"这个顶点对应图片上的哪个位置"。(0,0) 是图片左上角,(1,1) 是右下角。
贴图的原理像给纸盒贴包装纸:纸盒的每个角(顶点)标记"对应包装纸的哪个点",GPU 光栅化时自动把顶点之间的 UV 插值出来,每个像素拿着自己的 UV 去图片上"取样"颜色。
plain
 
顶点A(UV=0,0) -------- 顶点B(UV=1,0)
      中间的像素UV自动插值,比如(0.5,0) → 去图片正上方中央取色

采样器(Sampler)

着色器里不是直接读图片,而是通过"采样器"读,它决定:图片放大时是模糊(线性过滤)还是马赛克(最近邻)、UV 超出 0~1 时是重复还是截断。

图像布局转换(Image Layout Transition)

GPU 图片在不同用途下有不同"内部排布方式"(布局),换用途前必须显式"转换布局":
plain
 
创建时: UNDEFINED →(转换)→ TRANSFER_DST(能被memcpy搬运)
搬运完: TRANSFER_DST →(转换)→ SHADER_READ_ONLY(能着色器读取)
这是 Vulkan 新手第三大坑:忘了转换布局,画面全黑或花屏。

9.3 中文伪代码流程

plain
 
启动(在例5立方体基础上加):
  顶点数据:每个顶点加2个float的UV坐标
  loadTexture("box.jpg"):
      stb_image库读图片 → 得到 RGBA 像素数组
      像素数组 → 暂存Buffer(例7学的手法)
      创建 VkImage(GPU本地显存的图片对象)
      转换布局:UNDEFINED → TRANSFER_DST
      vkCmdCopyBufferToImage 把像素搬进图片
      转换布局:TRANSFER_DST → SHADER_READ_ONLY
      创建 ImageView + Sampler
  描述符:新增一个绑定点(类型=COMBINED_IMAGE_SAMPLER)指向纹理
  顶点格式说明书:加一条UV属性

每帧:和例5完全一样(贴图是静态资源,不需要每帧更新)

9.4 着色器变化

shader.vert——UV 像颜色一样,从顶点透传给片段:
glsl
 
#version 450
layout(binding = 0) uniform UniformBufferObject {
    mat4 model; mat4 view; mat4 proj;
} ubo;

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inColor;
layout(location = 2) in vec2 inTexCoord;   // ★新增:UV坐标
layout(location = 0) out vec3 fragColor;
layout(location = 1) out vec2 fragTexCoord; // ★透传给片段着色器

void main() {
    gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 1.0);
    fragColor = inColor;
    fragTexCoord = inTexCoord;
}
shader.frag——核心就一行:拿 UV 去纹理上取色:
glsl
 
#version 450
layout(location = 0) in vec3 fragColor;
layout(location = 1) in vec2 fragTexCoord;          // 插值后的UV
layout(binding = 1) uniform sampler2D texSampler;   // ★纹理+采样器(绑定点1)
layout(location = 0) out vec4 outColor;

void main() {
    vec4 texColor = texture(texSampler, fragTexCoord);  // ★取样:UV处图片的颜色
    outColor = texColor * vec4(fragColor, 1.0);         // 贴图颜色 × 顶点颜色(可染色)
}

9.5 C++ 代码

cpp
 
// stb_image:单头文件图片解码库,GitHub下载 stb_image.h
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"

// ---------- 顶点加UV ----------
struct Vertex {
    glm::vec3 pos;
    glm::vec3 color;
    glm::vec2 uv;        // ★新增
};
// 立方体数据示意(正面4个顶点,UV正好铺满整张图):
// {-0.5,-0.5, 0.5, 颜色, 0.0, 1.0}  ← 左下角对应图片左下
// { 0.5,-0.5, 0.5, 颜色, 1.0, 1.0}
// { 0.5, 0.5, 0.5, 颜色, 1.0, 0.0}
// {-0.5, 0.5, 0.5, 颜色, 0.0, 0.0}

// ---------- 纹理成员 ----------
VkImage textureImage;
VkDeviceMemory textureImageMemory;
VkImageView textureImageView;
VkSampler textureSampler;

// ---------- 加载纹理 ----------
void createTextureImage(const std::string& path) {
    // 1. 解码图片文件 → RGBA像素数组
    int texWidth, texHeight, texChannels;
    stbi_uc* pixels = stbi_load(path.c_str(), &texWidth, &texHeight,
                                &texChannels, STBI_rgb_alpha); // 强制转RGBA四通道
    if (!pixels) throw std::runtime_error("图片加载失败: " + path);
    VkDeviceSize imageSize = texWidth * texHeight * 4;   // 每像素4字节

    // 2. 像素 → 暂存Buffer(例7的手法)
    VkBuffer stagingBuffer; VkDeviceMemory stagingMemory;
    createRawBuffer(imageSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, true,
                    stagingBuffer, stagingMemory);
    void* data;
    vkMapMemory(device, stagingMemory, 0, imageSize, 0, &data);
    memcpy(data, pixels, imageSize);
    vkUnmapMemory(device, stagingMemory);
    stbi_image_free(pixels);   // CPU端的像素数组已经没用了

    // 3. 创建GPU图片对象(结构和深度图那张几乎一样,usage不同)
    VkImageCreateInfo imageInfo{};
    imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
    imageInfo.imageType = VK_IMAGE_TYPE_2D;
    imageInfo.extent = { (uint32_t)texWidth, (uint32_t)texHeight, 1 };
    imageInfo.mipLevels = 1;
    imageInfo.arrayLayers = 1;
    imageInfo.format = VK_FORMAT_R8G8B8A8_SRGB;    // RGBA每通道8位
    imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
    imageInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT     // 能被搬运
                    | VK_IMAGE_USAGE_SAMPLED_BIT;         // 能被着色器采样
    imageInfo.samples = VK_SAMPLE_COUNT_1_BIT;
    imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
    vkCreateImage(device, &imageInfo, nullptr, &textureImage);
    // ...分配绑定GPU本地内存(略,同深度图)...

    // 4. 布局转换 → 搬运 → 再转换
    transitionImageLayout(textureImage, VK_IMAGE_LAYOUT_UNDEFINED,
                          VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL);
    copyBufferToImage(stagingBuffer, textureImage, texWidth, texHeight);
    transitionImageLayout(textureImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
                          VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);

    vkDestroyBuffer(device, stagingBuffer, nullptr);
    vkFreeMemory(device, stagingMemory, nullptr);
}

// ---------- 布局转换:用"管线屏障"命令实现 ----------
void transitionImageLayout(VkImage image, VkImageLayout oldLayout, VkImageLayout newLayout) {
    // 录一条一次性命令(和例7的copy命令同样的提交方式,略去begin/end/submit骨架)
    VkCommandBuffer cmd = beginSingleTimeCommands();

    VkImageMemoryBarrier barrier{};
    barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
    barrier.oldLayout = oldLayout;
    barrier.newLayout = newLayout;
    barrier.image = image;
    barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
    barrier.subresourceRange.levelCount = 1;
    barrier.subresourceRange.layerCount = 1;

    // 根据转换方向设置"谁要等谁"(同步规则)
    VkPipelineStageFlags srcStage, dstStage;
    if (oldLayout == VK_IMAGE_LAYOUT_UNDEFINED &&
        newLayout == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL) {
        barrier.srcAccessMask = 0;
        barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
        srcStage = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
        dstStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
    } else {  // TRANSFER_DST → SHADER_READ_ONLY
        barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
        barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
        srcStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
        dstStage = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;
    }
    vkCmdPipelineBarrier(cmd, srcStage, dstStage, 0,
                         0, nullptr, 0, nullptr, 1, &barrier);
    endSingleTimeCommands(cmd);
}

// ---------- 搬运:Buffer → Image ----------
void copyBufferToImage(VkBuffer buffer, VkImage image, uint32_t w, uint32_t h) {
    VkCommandBuffer cmd = beginSingleTimeCommands();
    VkBufferImageCopy region{};
    region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
    region.imageSubresource.layerCount = 1;
    region.imageExtent = { w, h, 1 };
    vkCmdCopyBufferToImage(cmd, buffer, image,
                           VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &region);
    endSingleTimeCommands(cmd);
}

// ---------- ImageView(同交换链的做法,略) + 采样器 ----------
void createTextureSampler() {
    VkSamplerCreateInfo samplerInfo{};
    samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
    samplerInfo.magFilter = VK_FILTER_LINEAR;    // 放大时:线性模糊(平滑)
    samplerInfo.minFilter = VK_FILTER_LINEAR;    // 缩小时:线性模糊
    samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT; // UV超出1就重复平铺
    samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT;
    samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT;
    vkCreateSampler(device, &samplerInfo, nullptr, &textureSampler);
}

// ---------- 描述符:增加纹理绑定点 ----------
// 布局:绑定点0=uniform(MVP),绑定点1=纹理
// layoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
// layoutBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;   // ★片段着色器用!

// 写描述符时:
VkDescriptorImageInfo imageInfo{};
imageInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
imageInfo.imageView = textureImageView;
imageInfo.sampler = textureSampler;
// VkWriteDescriptorSet 的 pImageInfo = &imageInfo(原来用pBufferInfo,这次用pImageInfo)

9.6 本章规律总结

  1. 贴图 = 顶点带 UV + 片段着色器按 UV 取色。就这两件事,其余全是资源管理的体力活。
  2. 纹理的描述符五部曲和 Uniform 完全同构,只是类型换成 COMBINED_IMAGE_SAMPLER、用 pImageInfo。
  3. 布局转换是 Vulkan 图片操作的固定伴随动作:改用途,先转换。例5 的深度图其实也隐含了布局问题(渲染通道帮我们自动处理了)。

例10:光照与综合场景(毕业作品)

10.1 目标

大结局:一个完整小场景——一盏"太阳"灯 + 地面上摆着导入的模型 + 木箱贴图 + 所有物体按动画系统运动。学完你就见过一个真实 3D 程序的全部主要部件。

10.2 本章新概念

光照是怎么算出来的

没有光照时,物体是"平"的(例5 的立方体靠顶点颜色区分面)。真实感来自:面向光的面亮,背向光的面暗。
最经典的 Phong 光照 = 三项相加:
plain
 
最终颜色 = 环境光(Ambient) + 漫反射(Diffuse) + 高光(Specular)

环境光:   一个常数底色,模拟"房间里到处反射的光",让背光面不至于全黑
漫反射:   亮度 ∝ cos(法线, 光照方向的夹角) —— 面越正对光越亮
高光:     模拟"反光亮点",亮度 ∝ 反射方向与视线方向的接近程度
需要的原料:法线(Normal)——每个面/顶点"朝向"的单位向量。例7 加载 OBJ 时存的 vn 就是它。
计算放在片段着色器里做(每个像素算一次光照,效果好),这需要顶点着色器把"世界坐标"和"世界法线"插值传过来。

多个物体 + 多份资源的管理

综合场景里每个物体有自己的:顶点/索引缓冲、纹理、描述符、M 矩阵。工程做法是把它们打包成一个 struct Mesh / struct Object,用 std::vector<Object> 管理。这就是"渲染器"的萌芽。

10.3 中文伪代码流程(整个程序俯瞰)

plain
 
启动:
  for 每个物体 in 场景配置表:
      加载模型(OBJ) → 顶点/索引缓冲(经暂存)
      加载贴图(jpg) → 纹理Image/View/Sampler
      分配该物体的 DescriptorSet(绑定点0:MVP、绑定点1:光照参数、绑定点2:纹理)
  创建地面(一个大平面,手写的4个顶点+2个三角形)

每帧:
  更新时间 t
  for 每个物体:
      物体的M = 动画系统(t)           ← 例8
      光照参数 = {太阳方向, 太阳颜色, 相机位置}
      memcpy → 该物体的Uniform
  录命令:
      for 每个物体:
          绑定该物体的管线/顶点缓冲/索引缓冲/描述符
          vkCmdDrawIndexed(...)
  提交、呈现

10.4 着色器(本章的重头戏在片段着色器)

shader.vert——多输出两个量:世界坐标、世界法线:
glsl
 
#version 450
layout(binding = 0) uniform MVP {
    mat4 model; mat4 view; mat4 proj;
} mvp;

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;    // 法线(模型文件里的vn)
layout(location = 2) in vec2 inTexCoord;

layout(location = 0) out vec3 fragWorldPos;    // 顶点在世界中的位置(算光照要用)
layout(location = 1) out vec3 fragWorldNormal; // 顶点在世界中的朝向
layout(location = 2) out vec2 fragTexCoord;

void main() {
    vec4 worldPos = mvp.model * vec4(inPosition, 1.0);
    fragWorldPos = worldPos.xyz;
    // 法线也要跟着M矩阵转(严格说要用"法线矩阵",教学简化直接用model的旋转部分)
    fragWorldNormal = mat3(mvp.model) * inNormal;
    fragTexCoord = inTexCoord;
    gl_Position = mvp.proj * mvp.view * worldPos;
}
shader.frag——Phong 光照三项相加:
glsl
 
#version 450
// 光照参数(第二个公告栏)
layout(binding = 1) uniform LightUBO {
    vec3 lightDir;     // 太阳照过来的方向
    vec3 lightColor;   // 太阳颜色(白色偏暖)
    vec3 viewPos;      // 相机位置(算高光要用)
} light;

layout(binding = 2) uniform sampler2D texSampler;

layout(location = 0) in vec3 fragWorldPos;
layout(location = 1) in vec3 fragWorldNormal;
layout(location = 2) in vec2 fragTexCoord;
layout(location = 0) out vec4 outColor;

void main() {
    vec3 baseColor = texture(texSampler, fragTexCoord).rgb;  // 物体本来的颜色(贴图)
    vec3 N = normalize(fragWorldNormal);      // 法线单位化(插值后长度变了,要重新归一)
    vec3 L = normalize(-light.lightDir);      // 指向太阳的方向

    // ① 环境光:常数底色,背光面也不会全黑
    vec3 ambient = 0.15 * light.lightColor;

    // ② 漫反射:面越正对光越亮。dot(法线,光方向)=cos夹角,负数截断为0
    float diff = max(dot(N, L), 0.0);
    vec3 diffuse = diff * light.lightColor;

    // ③ 高光:反射光正好射进眼睛时出现亮点
    vec3 V = normalize(light.viewPos - fragWorldPos);   // 指向眼睛的方向
    vec3 R = reflect(-L, N);                            // 光的镜面反射方向
    float spec = pow(max(dot(V, R), 0.0), 32.0);        // 32=高光集中度,越大亮点越小
    vec3 specular = 0.5 * spec * light.lightColor;

    // 三项相加 × 物体本色 = 最终颜色
    vec3 result = (ambient + diffuse + specular) * baseColor;
    outColor = vec4(result, 1.0);
}

10.5 C++ 综合场景骨架

cpp
 
// ---------- 场景对象:把一个物体的全部资源打包 ----------
struct SceneObject {
    std::vector<Vertex> vertices;
    std::vector<uint32_t> indices;
    VkBuffer vertexBuffer;  VkDeviceMemory vertexMemory;
    VkBuffer indexBuffer;   VkDeviceMemory indexMemory;
    VkImage texture;        VkDeviceMemory textureMemory;
    VkImageView textureView;
    VkDescriptorSet descriptorSet;
    VkBuffer uniformBuffer; VkDeviceMemory uniformMemory;
    void* uniformMapped;
    // 动画参数(每个物体的运动方式)
    glm::vec3 orbitCenter; float orbitRadius; float orbitSpeed; float scale;
};

std::vector<SceneObject> scene;   // 整个场景 = 一堆物体

// ---------- 场景配置表:数据驱动!加物体不用改逻辑 ----------
void buildScene() {
    // 地面:手写的大平面 + 灰色
    scene.push_back(makePlane(20.0f, "floor.jpg", /*不动*/));
    // 木箱:绕(0,0,0)转,半径3,速度1
    scene.push_back(makeObject("cube.obj", "box.jpg",
                               {0,0,0}, 3.0f, 1.0f, 1.0f));
    // 兔子:绕(5,0,5)转,半径2,速度2,缩小一半
    scene.push_back(makeObject("bunny.obj", "fur.jpg",
                               {5,0,5}, 2.0f, 2.0f, 0.5f));
}

// ---------- 每帧:先更新全部物体的矩阵,再一次录命令画全部 ----------
void drawFrame() {
    // ...等栅栏、领画布(略)...
    for (auto& obj : scene) {
        glm::mat4 M = computeOrbitMatrix(obj, getTime());  // 例8的动画
        // memcpy {M, V, P, 光照参数} → obj.uniformMapped
    }
    // 录命令:
    // for (auto& obj : scene) {
    //     vkCmdBindVertexBuffers(..., &obj.vertexBuffer, ...);
    //     vkCmdBindIndexBuffer(..., obj.indexBuffer, ...);
    //     vkCmdBindDescriptorSets(..., &obj.descriptorSet, ...);
    //     vkCmdDrawIndexed(..., obj.indices.size(), ...);
    // }
    // ...提交、呈现(略)...
}

10.6 全书终极规律总结

回头看十个例子,你会惊觉:从例5 之后,Vulkan 的骨架几乎没再变过。后面所有"新内容"都只是三种操作的排列组合:
表格
 
 
操作 出现过的例子
顶点格式加一种属性(颜色→UV→法线) 例2、例9、例10
描述符加一种资源(矩阵→纹理→光照参数) 例3、例9、例10
数据上传走暂存缓冲 例7(Buffer)、例9(Image)
而 3D 程序的全部灵魂,可以浓缩成五行:
plain
 
1. 顶点 + 索引  →  物体的形状
2. MVP 矩阵     →  物体在哪、相机在哪
3. 纹理 + UV    →  物体的皮肤
4. 法线 + 光照  →  物体的立体感
5. 时间 + 插值  →  让一切动起来

10.7 学完之后的路线图

  1. 巩固:把例5 默写一遍(不看文档)。能默写立方体,说明骨架真的懂了。
  2. 下一步(进阶):
    • 校验层(Validation Layers):打开它,Vulkan 会告诉你每个错误在哪——正式开发必开。
    • 多帧并行(Frames in Flight):2~3 帧同时在飞,榨干性能。
    • glTF + 骨骼动画(例8 预告的下一站)。
    • Mipmap:远处纹理不闪烁的秘密。
    • 离屏渲染、阴影贴图(Shadow Mapping)。
  3. 推荐资源:官方教程 vulkan-tutorial.com(本文的十个例子就是它的教学主线)、Khronos 的 Vulkan Guide、《Vulkan Cookbook》。
给初中生和小学生朋友  的一句话:Vulkan 看起来很吓人,是因为它把 GPU 工厂的每个齿轮都让你亲手装一遍。但装过一次你会发现——全世界所有 3D 程序,从手机游戏到电影特效,都是这五个灵魂的组合。你已经进门了。真正的技术基础不是难,只是好多概念陌生,我们这篇大家可以每天读几遍,先熟悉概念,关健字,以后就不会陌生了
posted @ 2026-07-21 09:26  鬼门元歌  阅读(22)  评论(0)    收藏  举报