Ch4-1 着色器模组

着色器是在管线的可编程阶段运行的GPU程序。
通常使用适合人类阅读和书写的编程语言GLSL编写着色器(也可以用微软的HLSL),然后将着色器编译到SPIR-V这一中间语言,后缀名为.spv。
之后再由Vulkan程序读取.spv文件,由显卡驱动提供的Vulkan实现将其编译为着色器模组(VkShaderModule)。
为什么不事先一步到位地编译?因为不同的显卡驱动所提供的Vulkan实现的编译结果可能不同。

本节先讲述Vulkan API中与着色器模组相关的接口,再说明编写用于Vulkan的GLSL着色器时的一些通用常识。

着色器模组

创建着色器模组

vkCreateShaderModule(...)创建着色器模组:

VkResult VKAPI_CALL vkCreateShaderModule(...) 的参数说明

VkDevice device

逻辑设备的handle

const VkShaderModuleCreateInfo* pCreateInfo

指向VkFramebuffer的创建信息

const VkAllocationCallbacks* pAllocator

VkShaderModule* pShaderModule

若创建成功,将着色器模组的handle写入*pShaderModule

struct VkShaderModuleCreateInfo 的成员说明

VkStructureType sType

结构体的类型,本处必须是VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO

const void* pNext

如有必要,指向一个用于扩展该结构体的结构体

VkShaderModuleCreateFlags flags

size_t codeSize

SPIR-V代码的大小,单位是字节,必须是4的倍数

const uint32_t* pCode

指向SPIR-V代码

VkShaderModuleCreateInfo中没有任何有必要特地说明的地方。关于如何从.spv文件读取SPIR-V代码到内存,这里给出C++代码:

std::ifstream file(filepath, std::ios::ate | std::ios::binary);
if (!file) {
    /*错误信息,此处略*/
}
size_t fileSize = size_t(file.tellg());
std::vector<uint32_t> binaries(fileSize / 4);
file.seekg(0);
file.read(reinterpret_cast<char*>(binaries.data()), fileSize);
file.close();

管线着色器阶段的创建信息

创建渲染管线时需要着色器阶段的创建信息(VkPipelineShaderStageCreateInfo):

struct VkPipelineShaderStageCreateInfo 的成员说明

VkStructureType sType

结构体的类型,本处必须是VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO

const void* pNext

如有必要,指向一个用于扩展该结构体的结构体

VkPipelineShaderStageCreateFlags flags

VkShaderStageFlagBits stage

着色器对应的可编程管线阶段

VkShaderModule module

着色器模组的handle

const char* pName

接入点函数名称,比如通常的主函数为"main"

const VkSpecializationInfo* pSpecializationInfo

常量的特化信息,若不需要特化常量,则为nullptr

  • 自Vulkan1.3起可以将flags指定为与计算着色器相关的VK_PIPELINE_SHADER_STAGE_CREATE_ALLOW_VARYING_SUBGROUP_SIZE_BITVK_PIPELINE_SHADER_STAGE_CREATE_REQUIRE_FULL_SUBGROUPS_BIT,本套教程中不作讲解。

可以用VkSpecializationInfo对管线中着色器的特定常量进行独有的特化:

struct VkSpecializationInfo 的成员说明

uint32_t mapEntryCount

需被特化的常量的个数

const VkSpecializationMapEntry* pMapEntries

各个常量的特化信息

size_t dataSize

特化数据的总大小,单位为字节

const void* pData

所有特化数据所在的内存地址

struct VkSpecializationMapEntry 的成员说明

uint32_t constantID

被特化常量的ID

uint32_t offset

对应的特化数据在VkSpecializationInfo::pData中的起始位置

size_t size

对应的特化数据的大小,单位为字节

  • 如果对应的常量是布尔类型,size为4(一个VkBool32的大小)。

这里以具体代码为例,说明常量特化的具体写法:

//GLSL代码
layout(constant_id = 0) const uint maxLightCount = 32;
layout(constant_id = 1) const uint shininess = 32;
  • 对于需要被特化的常量,在声明前加上layout(constant_id = ID编号)

以上代码声明了两个常量,其默认值皆指定为32。
试着在创建管线时对它们进行特化,将maxLightCount变为48,shininess变为64:

//函数体内部的C++代码
static constexpr struct {
    int32_t maxLightCount = 48;
    int32_t shininess = 64;
} constants;
VkSpecializationMapEntry specializationMapEntries[] = {
    { 0, 0, 4 },//开头的0对应constant_id = 0,之后的0对应结构体变量constants中成员maxLightCount的起始位置
    { 1, 4, 4 } //开头的1对应constant_id = 1,之后的4对应结构体变量constants中成员shininess的起始位置
};
VkSpecializationInfo specializationInfo = {
    .mapEntryCount = 2,
    .pMapEntries = specializationMapEntries,
    .dataSize = sizeof constants,
    .pData = &constants
};

封装为shaderModule类

VKBase.h,vulkan命名空间中添加以下代码:

class shaderModule {
    VkShaderModule handle = VK_NULL_HANDLE;
public:
    shaderModule() = default;
    shaderModule(VkShaderModuleCreateInfo& createInfo) {
        Create(createInfo);
    }
    shaderModule(const char* filepath /*VkShaderModuleCreateFlags flags*/) {
        Create(filepath);
    }
    shaderModule(size_t codeSize, const uint32_t* pCode /*VkShaderModuleCreateFlags flags*/) {
        Create(codeSize, pCode);
    }
    shaderModule(shaderModule&& other) noexcept { MoveHandle; }
    ~shaderModule() { DestroyHandleBy(vkDestroyShaderModule); }
    //Getter
    DefineHandleTypeOperator;
    DefineAddressFunction;
    //Const Function
    VkPipelineShaderStageCreateInfo StageCreateInfo(VkShaderStageFlagBits stage, const char* entry = "main") const {
        return {
            VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO,//sType
            nullptr,                                            //pNext
            0,                                                  //flags
            stage,                                              //stage
            handle,                                             //module
            entry,                                              //pName
            nullptr                                             //pSpecializationInfo
        };
    }
    //Non-const Function
    result_t Create(VkShaderModuleCreateInfo& createInfo) {
        createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
        VkResult result = vkCreateShaderModule(graphicsBase::Base().Device(), &createInfo, nullptr, &handle);
        if (result)
            outStream << std::format("[ shader ] ERROR\nFailed to create a shader module!\nError code: {}\n", int32_t(result));
        return result;
    }
    result_t Create(const char* filepath /*VkShaderModuleCreateFlags flags*/) {
        std::ifstream file(filepath, std::ios::ate | std::ios::binary);
        if (!file) {
            outStream << std::format("[ shader ] ERROR\nFailed to open the file: {}\n", filepath);
            return VK_RESULT_MAX_ENUM;//没有合适的错误代码,别用VK_ERROR_UNKNOWN
        }
        size_t fileSize = size_t(file.tellg());
        std::vector<uint32_t> binaries(fileSize / 4);
        file.seekg(0);
        file.read(reinterpret_cast<char*>(binaries.data()), fileSize);
        file.close();
        return Create(fileSize, binaries.data());
    }
    result_t Create(size_t codeSize, const uint32_t* pCode /*VkShaderModuleCreateFlags flags*/) {
        VkShaderModuleCreateInfo createInfo = {
            .codeSize = codeSize,
            .pCode = pCode
        };
        return Create(createInfo);
    }
};

标准化设备坐标系

编写Vulkan用的GLSL着色器时,首先需要注意的是其NDC(标准化设备坐标系,normalized device coordinates)。

相机参考系中的三维坐标加上齐次坐标w,经投影矩阵变换后,被转换到齐次剪裁空间(homogeneous clip space)坐标。将该四维矢量输出到gl_Position后,在栅格化阶段前,该四维矢量的xyz三个分量被除以w分量,由此得到的三维矢量即是NDC坐标(二维渲染中gl_Position.w应总是指定为1,那么gl_Position.xyz的数值就等同于NDC坐标)。

最终片段在图像中的具体坐标及深度值,是根据创建管线时指定的视口(viewport),将NDC坐标映射到屏幕空间(screen space)后得到的。

对比Vulkan与OpenGL的NDC:

Vulkan

OpenGL

可被渲染的x坐标的范围

[-1, 1]

[-1, 1]

+x方向

向右

向右

可被渲染的y坐标的范围

[-1, 1]

[-1, 1]

+y方向

向下

向上

可被渲染的z坐标的范围

[ 0, 1]

[-1, 1]

+z方向

因人而异

因人而异

  • +z方向无关紧要,远近的定义取决于如何进行深度测试。

之前在Ch1-1 创建GLFW窗口中已经定义了GLM_FORCE_DEPTH_ZERO_TO_ONE来指定深度范围为[ 0, 1]。GLM生成的投影矩阵适用于OpenGL,但对于Vulkan程序还需要解决+y方向问题,通过以下函数对投影矩阵略作修改即可:

inline glm::mat4 FlipVertical(const glm::mat4& projection) {
    glm::mat4 _projection = projection;
    for (uint32_t i = 0; i < 4; i++)
        _projection[i][1] *= -1;
    return _projection;
}

从GLSL编译到SPIR-V

你所下载到的Vulkan SDK中应该附带Bin/glslc.exe,这是谷歌提供的工具(官方Github文档见此),用于将由GLSL书写的着色器编译到.spv文件。

着色器类型判别

编译GLSL着色器到SPIR-V时,需要告诉glslc.exe该着色器对应的可编程阶段。
以下三种方式之一即可:
1.通过文件扩展名表示着色器类型
2.通过着色器中的预编译指令表示着色器类型,语法为#pragma shader_stage(着色器对应的阶段名称)
3.编译时显式指定,语法为-fshader-stage=着色器对应的阶段名称
以上三条按优先级从低到高排序,即显式指定的优先级最高,预编译指令其次。

管线阶段

文件扩展名

预编译指令/显式指定的阶段名称

顶点

.vert

vertex

片段

.frag

fragment

细分控制

.tesc

tesscontrol

细分求值

.tese

tesseval

几何

.geom

geometry

计算

.comp

compute

基础命令行语法

指定文件名:

glslc.exe路径 着色器文件路径 -o 输出文件路径
//若显式指定着色器对应的阶段名称
glslc.exe路径 着色器文件路径 -fshader-stage=阶段名称 -o 输出文件路径

自动命名:

glslc.exe路径 着色器文件路径 -c
//若显式指定着色器对应的阶段名称
glslc.exe路径 着色器文件路径 -fshader-stage=阶段名称 -c
  • 其实-fshader-stage=阶段名称-c-o 输出文件路径之间不分先后。

  • 若着色器文件的后缀为着色器类型(.vert等),自动命名的方式是在其后添加.spv,比如foo.vert变为foo.vert.spv。否则,自动命名的方式是替代扩展名为.spv,比如foo.vert.shader变为foo.vert.spv。

GLSL着色器的基本语法

版本声明

在着色器的开头须声明其版本,语法为:

#version 三位数版本号

截至我撰写这套教程为止,最新的GLSL版本为4.60,如下所示:

#version 460

接入点函数

同C++程序需要主函数一样,着色器需要一个主函数作为程序接入点,通常我会把它也称作main()。
注意着色器的接入点函数的返回值只能是void类型,即不能返回值,但你能在函数体中使用return关键字显式返回。
除了主函数外,你当然还能声明其他的函数。

数据类型

着色器中可用以下标量类型:

类型名

说明

bool

4字节,对应VkBool32

int

32位有符号整形

uint

32位无符号整形

float

32位浮点数

double

64位浮点数

着色器中可用以下矢量类型:

类型名

说明

bvec2

分量是bool类型,每个分量4字节

bvec3

同上

bvec4

同上

ivec2

分量是int类型

ivec3

同上

ivec4

同上

uvec2

分量是uint类型

uvec3

同上

uvec4

同上

vec2

分量是float类型

vec3

同上

vec4

同上

dvec2

分量是double类型

dvec3

同上

dvec4

同上

  • 以四维矢量为例,各个分量元素分别为x、y、z、w(通常用于表示坐标),或r、g、b、a(通常用于表示颜色),或者s、t、p、q(通常用于表示贴图坐标),这三种写法可以根据用途以示语义差别(写给人看),但语法上不存在差别。若要访问vec4 vector的第一个元素,应为vector.xvector.rvector.s

  • 通过在.后面连写分量名称,可以构成新的矢量。再以vec4 vector为例,vector.yxy表示一个vec3类型的矢量,其x和z分量的值等于vector的y分量,其y分量的值等于vector的x分量。注意类似vector.xrs的表达式不合法,连写的分量名称必须对应单种语义的写法。

  • 通过在.后面连写分量名称,标量也可以构成新的矢量。以float scalar为例,scalar.xx表示一个vec2类型的矢量,每个分量都等于scalar

着色器中可用以下单精度浮点数矩阵类型:

类型名

说明

mat2

2列2行,等价于mat2x2

mat2x3

2列3行

mat2x4

2列4行

mat3x2

3列2行

mat3

3列3行,等价于mat3x3

mat3x4

3列4行

mat4x2

4列2行

mat4x3

4列3行

mat4

4列4行,等价于mat4x4

  • GLSL中的矩阵默认是列主矩阵(但可以通过在类型前添加layout(row_major)来使用行主矩阵),GLM生成的变换矩阵也是列主矩阵。

  • 在类型名前加d,即为相应双精度浮点数矩阵类型。

访问矩阵元素时的格式为:矩阵变量名[列索引][行索引],比如:

mat3 matrix;
matrix[1] = vec3(3.0, 3.0, 3.0);//第二列元素全赋值为3.0
matrix[2][0] = 16.0; //第三列第一个元素赋值为16.0

着色器中可以定义数组类型,比如int a[2][3],同C语言中类似。
此外,着色器中也可以定义结构体类型,同C语言中类似。

图形着色器中通用的输入输出声明方式

用以下语法在着色器中声明输入输出:

//输入
layout(location = 索引) in 插值修饰符 类型 名称;
//或
layout(location = 索引) 插值修饰符 in 类型 名称;

//输出,若为片段着色器的输出,location为颜色附件索引
layout(location = 索引) out 插值修饰符 类型 名称;
//或
layout(location = 索引) 插值修饰符 out 类型 名称;
  • 可对特定阶段的输入输出使用插值修饰符,若省略插值修饰符,则默认为smooth

  • 输入来源于先前的阶段,输出则是输出到之后的阶段。当前阶段的输入要与先前阶段的输出匹配,需要匹配索引、插值修饰符、类型,名称可以不同。

  • 相应索引的颜色附件对应创建渲染通道时,VkSubpassDescription::pColorAttachments所指数组中相应VkAttachmentReference元素代表的颜色附件。

插值修饰符

可以对以下阶段的输入使用插值修饰符:
1.细分控制着色器(从顶点着色器到细分控制着色器本就不会插值,写插值修饰符为了跟顶点着色器中的修饰符匹配)
2.几何着色器(同样,写插值修饰符为了跟先前着色器中的修饰符匹配)
3.片段着色器
着色器的代码可能适用于某个阶段的着色器可有可无的情况,比如你可以构造出一组着色器,既能走“顶点→几何→片段”的流程,也能走“顶点→片段”的流程,如果片段着色器中需要特定插值修饰符的输入,那么顶点着色器中就得使用相应插值修饰符的输出,而为了匹配,尽管“顶点→几何”不需要插值,这里头几何着色器也得有同样插值修饰符的输入。

可以对以下阶段的输出使用插值修饰符:
1.顶点着色器
2.细分求值着色器
3.几何着色器

插值修饰符有以下三种:
1.smooth是默认的插值方式,它进行双曲插值,会根据gl_Position的w分量考虑透视的影响(经三维相机效果的投影矩阵变换后生成的顶点,透视的影响会反映在其w值上)。
2.flat不进行插值,对某一片段调用片段着色器时,flat的输入取得先前阶段中,该片段对应图元的激发顶点(provoking vertex)的相应值。
3.noperspective进行简单粗暴的线性插值,不考虑透视的影响。
如果输出的gl_Position.w总是为1(通常为渲染平面图形时),smoothnoperspective的效果没有差别。
关于激发顶点,请自行参阅Vulkan官方标准中的图示

下图左侧为通过noperspective插值的uv坐标绘制的正方体的两个面,右侧则是通过smooth插值的uv坐标绘制:

_images/ch4-1-1.png

Push Constant的声明方式

Push constant是在着色器中使用可变更(由CPU侧)常量的两种方式之一。
Push constant适用于少量数据,Vulkan的实现通常会确保你能在push constant块中使用128个字节。
这里介绍如何在着色器中声明push constant,关于更新push constant,参见Ch7-4 初识Push Constant

layout(内存布局修饰符, push_constant) uniform 块名称 {
    成员声明
} 实例名称;
  • 若省略内存布局修饰符,,则默认为std430,详见后文块成员的内存布局

  • 可以没有实例名称,如此一来能直接用成员名称访问块成员。否则用实例名称.成员名称来访问块成员。

块当中的成员可以前缀layout(offset = 距离整块数据起始位置的字节数)修饰,其具体用例:

//顶点着色器中
layout(push_constant) uniform pushConstants {
    mat4 proj;
    vec2 view;
    vec2 scale;
};
//片段着色器中
layout(push_constant) uniform pushConstants {
    layout(offset = 80)
    vec4 color;
};

以上代码摘自一组很简单的用于2D渲染的着色器,其中,顶点着色器中需要proj矩阵、viewscale两个矢量,而片段着色器只需要color,它们加在一起一共96个字节,可以全部放进push constant中,片段着色器中只需要声明color,但它和projviewscale在同一整块数据中,若不想在片段着色器中声明projviewscale,则必须写明color的offset。

Uniform缓冲区的声明方式

Uniform缓冲区(uniform buffer)是在着色器中使用可变更(由CPU侧)常量的两种方式之一,类似于HLSL中的常量缓冲区。
相比push constant,uniform缓冲区适用于大量数据。
这里介绍如何在着色器中声明uniform缓冲区,关于如何绑定uniform缓冲区,参见Ch7-5 初识Uniform缓冲区

layout(set = 描述符集索引, binding = 绑定索引) uniform 块名称 {
    成员声明
} 实例名称;
  • 若省略set = 描述符集索引,,则默认为0号描述符集。

  • 实例可以为数组,对应的描述符亦构成数组(创建相应描述符布局时VkDescriptorSetLayoutBinding::descriptorCount大于1)。

  • 同push constant一样,可以没有实例名称,如此一来能直接用成员名称访问块成员。

  • 同push constant一样,块当中的成员可以前缀layout(offset = 距离缓冲区起始位置的字节数)修饰。

其他Uniform对象的声明

注意到上文中声明push constant和uniform缓冲区时皆用到了uniform这个关键字。
Uniform对象指的是着色器中的运行期常量(所谓运行期,指其并非编译期或装配管线时指定),只读不写,且在单次绘制命令的调用中不会改变其数据。

以下几种uniform对象能以类似的方式声明:

  • 各类贴图:以texture、itexture、utexture开头的一系列类型,如texture2D。无前缀、i、u前缀分别对应浮点、有符号整形和无符号整形(所涉及注意事项见Ch7-7 使用贴图)。

  • 采样器:有samplersamplerShadow两种。

  • 带采样器的贴图:以sampler、isampler、usampler开头的一系列类型,如sampler2Dsampler2DShadow。带Shadow后缀的无i或u前缀版本。

  • Uniform纹素缓冲区:有textureBufferitextureBufferutextureBuffer三种(写成OpenGL中定义的samplerBufferisamplerBufferusamplerBuffer也没差,反正都跟采样器没关系)。

Uniform对象的声明方式非常简单,与uniform缓冲区很相似,只是不需要块:

layout(set = 描述符集索引, binding = 绑定索引) uniform 类型 实例名称;
  • 类似uniform缓冲区,若省略set = 描述符集索引,,则默认为0号描述符集。

  • 类似uniform缓冲区,实例可以为数组,对应的描述符构成数组。

输入附件的的声明

最后还剩一种uniform对象:子通道输入(subpass input),即输入附件(input attachment)在GLSL中的概念,有subpssInputsubpssInputMSisubpssInputisubpssInputMSusubpssInputusubpssInputMS六种类型,MS后缀说明是多重采样附件。
输入附件的声明方式为:

layout(set = 描述符集索引, binding = 绑定索引, input_attachment_index = 输入附件索引) uniform 类型 实例名称;
  • 类似uniform缓冲区,若省略set = 描述符集索引,,则默认为0号描述符集。

输入附件索引对应VkSubpassDescription::pInputAttachments所指代的相应输入附件。
实例可以为数组,对应的描述符构成数组。啊?你问这要怎么构成数组?以下式为例:
layout(binding = 0, input_attachment_index = 基础索引) uniform subpassInput u_GBuffers[3];
通过表达式实例名称[N]进行访问时,访问到VkSubpassDescription::pInputAttachments[基础索引 + N]指代的输入附件。
这是Vulkan的GLSL方言中规定的。在将输入附件对应的image view写入描述符时应当注意顺序。

关于输入附件的具体用例见:Ch8-3 延迟渲染

Storage缓冲区的声明

//TODO 等写计算着色器的示例再写。

其他Storage对象的声明

//TODO 等写计算着色器的示例再写。

块成员的内存布局

Vulkan的GLSL着色器中,对于块,允许两种内存布局:std140和std430。不同的内存布局遵循不同的对齐规则。
Uniform缓冲区的内存布局只能为std140。Storage缓冲区和push constant的默认内存布局为std430,可以指定为std140。

内存布局涉及到的对齐规则十分重要,关系到如何在C++中定义相应的结构体。
先来看一个简单的例子:

layout(push_constant) uniform pushConstants {
    mat4 proj;         //offset为0
    vec2 view;         //offset为64
    vec2 scale;        //offset为72
    float width;       //offset为80
    float cornerRadius;//offset为84
    vec4 color0;       //offset为96
    vec4 color1;       //offset为112
};
  • cornerRadius的大小是4,但color0的offset一下子从88变成了96,这是因为vec4的对齐是16,它距离缓冲区开头的位置必须为16的整数倍。

std430的对齐遵循以下规则:
1.大小为N的标量,对齐为N。
2.二维矢量,每个分量大小为N,则对齐为2N。
3.三维及四维矢量,每个分量大小为N,则对齐为4N(注意,没有12字节对齐)。
4.C列R行的列主矩阵的对齐,相当于R维矢量的对齐。
5.结构体的对齐,取其成员大小中最大的对齐。
6.数组的对齐,取其单个元素大小的对齐。

std140的对齐比std430更严格:
1.结构体的对齐,取其成员大小中最大的对齐并凑整到16的倍数。
2.数组的对齐,取其单个元素大小的对齐并凑整到16的倍数,且数组的每个元素之间的步长等于数组的对齐(即步长也凑整到16的倍数,这会导致由16个int构成的数组的大小是256字节而不是64字节)。

在C++中,可以通过前缀alignas(...)来指定对齐:

struct {
    alignas(16) mat4 proj;         //offset为0
    alignas( 8) vec2 view;         //offset为64
    alignas( 8) vec2 scale;        //offset为72
    alignas( 4) float width;       //offset为80
    alignas( 4) float cornerRadius;//offset为84
    alignas(16) vec4 color0;       //offset为96
    alignas(16) vec4 color1;       //offset为112
} constant;

GLM中提供了的aligned type来帮你在一定程度上省事:

#define GLM_FORCE_ALIGNED_GENTYPES
#include <gtc/type_aligned.hpp>
//通过using来简化太长的名称
using  vec2a = glm::aligned_vec2; //对齐为 8
using  vec3a = glm::aligned_vec3; //对齐为16
using  vec4a = glm::aligned_vec4; //对齐为16

using dvec2a = glm::aligned_dvec2;//对齐为16
using dvec3a = glm::aligned_dvec3;//对齐为32
using dvec4a = glm::aligned_dvec4;//对齐为32

using ivec2a = glm::aligned_ivec2;//对齐为 8
using ivec3a = glm::aligned_ivec3;//对齐为16
using ivec4a = glm::aligned_ivec4;//对齐为16

using uvec2a = glm::aligned_uvec2;//对齐为 8
using uvec3a = glm::aligned_uvec3;//对齐为16
using uvec4a = glm::aligned_uvec4;//对齐为16

using  mat3a = glm::aligned_mat3; //对齐为16
using  mat4a = glm::aligned_mat4; //对齐为16

注意GLM提供的aligned type也存在问题:

//GLSL代码,块大小为16
layout(push_constant) uniform pushConstants {
    vec3 color;
    float alpha;
};
//C++代码,结构体大小为32
struct {
    glm::aligned_vec3 color;
    float alpha;
} constant;

因为glm::aligned_vec3的大小和对齐都是16(理想的情况是,大小为12,但对齐为16),所以C++代码中alpha没有填入12~16字节间的空位上。
我建议始终手动alignas(...)而非使用GLM的aligned type。

可特化常量的声明方式和使用

如前文所述,用以下语法声明可特化的常量:

layout(constant_id = ID编号) const 类型 常量名称;

需要注意的是,若在uniform块中声明数组时,应当只有一个数组的大小被可特化常量指定,且该数组应该位于块的最后:

//声明一个可以被特化的常量
layout(constant_id = 0) const uint maxLightCount = 32;
//Case 1: 这么做可以
layout(binding = 0) uniform descriptorConstants {
    vec3 cameraPosition;
    int lightCount;
    light lights[maxLightCount];//light是自定义结构体类型,定义略
};
//Case 2: 不要这么做
layout(binding = 0) uniform descriptorConstants {
    light lights[maxLightCount];
    vec3 cameraPosition;
    int lightCount;
};
  • 各个块成员的offset是被静态计算的,也就是说,Case 2这种情况,cameraPosition在缓冲区中的位置是根据maxLightCount的默认值进行计算的。假设可以在创建管线时改变maxLightCount(所谓假设,是因为这种情况下创建管线时Vulkan的验证层会报错),cameraPosition也总是在32个light的大小之后的位置。