系统掌握从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 本章规律总结
-
程序骨架四段式:
initWindow → initVulkan → mainLoop → cleanup,十个例子全一样。 -
initVulkan 的 12 步顺序基本固定,只有"管线配置"和"数据上传"两步在各例子间变化。
-
每帧固定动作:等栅栏 → 领画布 → 录命令 → 提交 → 呈现。
-
画线还是画三角形,只差管线里一个枚举
topology的值。
例2:画多条位置不同的彩色线
2.1 目标
屏幕上画一个彩色的"米"字形:4 条线段(8 个顶点),每条线颜色不同。
2.2 本章新概念
-
顶点带"附属信息":顶点不只有坐标,还可以带颜色。GPU 会自动把两个端点的颜色在中间的每个像素上插值渐变(从红渐变到蓝),这是免费得到的效果。
-
顶点数据格式变了:原来每个顶点 3 个 float(坐标),现在 6 个 float(坐标+颜色)。管线的"顶点格式说明书"要跟着改。
-
着色器之间传值:顶点着色器把颜色"递"给片段着色器(用
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 本章规律总结
-
顶点数据是"数组",格式说明书告诉 GPU 怎么切:
stride是每个顶点占多少字节,offset是每种信息在顶点内部的起始位置。这就是"顶点格式"的全部秘密。 -
GLSL 的 location 编号 ↔ C++ 的 attribute.location 必须一一对应,对不上就花屏或报错。
-
GPU 免费做插值:线段两端颜色不同,中间自动渐变。以后 3D 模型的平滑光照、纹理贴图全靠这个机制。
-
想画更多线?往数组里继续加顶点就行,框架一行不用改。数据驱动——这是 GPU 编程的核心思想。
例3:2D 动画——让图形动起来
3.1 目标
一个三角形绕屏幕中心不停旋转。
3.2 本章新概念
动画的本质:每帧的画面都不一样。让画面变化有三条路:
-
每帧改顶点数据(CPU 改,慢,留给例4);
-
每帧给着色器传一个"参数"(比如旋转角度),让 GPU 在顶点着色器里算出不同位置(快,本章学这个);
-
每帧改画图命令(比如画不同数量的顶点)。
本章走第 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 本章规律总结
-
动画 = 每帧改 Uniform,不改顶点。GPU 编程的黄金法则:能交给 GPU 算的,别让 CPU 改数据。
-
矩阵 × 顶点 = 移动顶点。旋转、平移、缩放全都统一成"乘一个矩阵",这就是 3D 图形学的数学基石。
-
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 本章规律总结
-
现在两条动画路线都见过了:改参数(Uniform,例3) vs 改数据(顶点缓冲,例4)。能用前者就别用后者——CPU 写 200 个点还好,写 200 万个点就会成为瓶颈。
-
"长期映射 + 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 本章规律总结
-
3D = 2D + 三样东西:MVP 矩阵(立体感)、索引缓冲(省内存)、深度缓冲(遮挡关系)。加上这三样,立方体就"立"起来了。
-
glm::perspective之后记住proj[1][1] *= -1(GLM 与 Vulkan 坐标系差异),这是每个 Vulkan 新手必踩的坑。 -
至此你已经掌握了现代 3D 渲染的完整最小闭环。后面五个例子全是"在这个闭环上加东西"。
例6:多个 3D 物体——一百个立方体
6.1 目标
同一个立方体画 100 份,排成 10×10 的方阵,各自绕自己的轴旋转。
6.2 本章新概念
关键问题:100 个物体的 M 矩阵各不相同,怎么传?
两种思路:
-
每个物体一个 DescriptorSet——太浪费;
-
一个大的 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 本章规律总结
-
同一份模型数据 + 不同的 M 矩阵 = 场景里的一群物体。顶点缓冲永远只有一份。
-
动态 Uniform是"一批同类物体"的标准做法;理解了对齐(alignment)要求就没什么神秘的。
-
一条命令缓冲里画 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, ©Cmd);
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, ©Region); // ★搬运命令
vkEndCommandBuffer(copyCmd);
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = ©Cmd;
vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE);
vkQueueWaitIdle(graphicsQueue); // 等叉车搬完(简单起见直接等)
// 5. 中转站用完就拆
vkFreeCommandBuffers(device, commandPool, 1, ©Cmd);
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 本章规律总结
-
加载模型 = 文本解析 + 填数组,填完之后的渲染流程和手写立方体一字不差。
-
暂存缓冲两步走是正式工程的标准传数据姿势:CPU 可见的中转站 → GPU 本地显存。例9 传贴图图片时还要再用一次(那时搬的是 Image 不是 Buffer)。
-
offsetof+ 结构体顶点,是顶点格式说明书的工程写法,以后所有例子都用它。
例8:3D 动画——让模型动起来(关键帧与插值)
8.1 目标
加载的模型不再是死物:让它一边旋转一边上下"漂浮",并沿一条轨道移动。进一步,理解"骨骼动画"的基本思想。
8.2 本章新概念
动画的三种层次(由简到难)
-
变换动画(本章):整个模型的 M 矩阵随时间变化(移动/旋转/缩放组合)。适合:车辆、门、机械臂整体运动。
-
顶点动画:顶点坐标本身随时间变化(例4 的 3D 版)。适合:波浪、软体。
-
骨骼动画(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 本章规律总结
-
动画 = 时间 → 矩阵。不管多复杂的动画,到渲染层面都只是"M 矩阵(或骨头矩阵)每帧不同"。
-
插值(lerp/mix)是动画的数学心脏:知道两端,中间按比例混合。
-
矩阵顺序
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, ®ion);
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 本章规律总结
-
贴图 = 顶点带 UV + 片段着色器按 UV 取色。就这两件事,其余全是资源管理的体力活。
-
纹理的描述符五部曲和 Uniform 完全同构,只是类型换成
COMBINED_IMAGE_SAMPLER、用pImageInfo。 -
布局转换是 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 学完之后的路线图
-
巩固:把例5 默写一遍(不看文档)。能默写立方体,说明骨架真的懂了。
-
下一步(进阶):
-
校验层(Validation Layers):打开它,Vulkan 会告诉你每个错误在哪——正式开发必开。
-
多帧并行(Frames in Flight):2~3 帧同时在飞,榨干性能。
-
glTF + 骨骼动画(例8 预告的下一站)。
-
Mipmap:远处纹理不闪烁的秘密。
-
离屏渲染、阴影贴图(Shadow Mapping)。
-
-
推荐资源:官方教程 vulkan-tutorial.com(本文的十个例子就是它的教学主线)、Khronos 的 Vulkan Guide、《Vulkan Cookbook》。
给初中生和小学生朋友 的一句话:Vulkan 看起来很吓人,是因为它把 GPU 工厂的每个齿轮都让你亲手装一遍。但装过一次你会发现——全世界所有 3D 程序,从手机游戏到电影特效,都是这五个灵魂的组合。你已经进门了。真正的技术基础不是难,只是好多概念陌生,我们这篇大家可以每天读几遍,先熟悉概念,关健字,以后就不会陌生了
浙公网安备 33010602011771号