Ch4-4 几何着色器

几何着色器(geometry shader)是图形管线中的一个着色器,它可以基于所输入图元的顶点数据,或在没有输入顶点数据的情况下根据一定的规则,生成新图元的顶点数据。
几何着色器亦可用来在开启多视口时指定渲染到的视口,或在多层渲染(layered rendering)中指定渲染到的图层。
对于一个有几何着色器的图形管线,几何着色器在管线中的执行晚于顶点着色器,或细分求值着色器(若使用细分),早于片段着色器。

几何着色器的内置输入

用GLSL编写的几何着色器有以下内置输入:

gl_PerVertex {
    vec4 gl_Position;
    float gl_PointSize;
    float gl_ClipDistance[];
    float gl_CullDistance[];
} gl_in[];

int gl_PrimitiveIDIn;
int gl_InvocationID;
  • gl_PerVertex的语义见顶点着色器的内置输出

  • gl_PerVertex的数据是以具名数组gl_in的形式输入的,即必须通过形如gl_in[i].gl_Position的形式访问内置输入的顶点数据,关于gl_in的数组长度见后文。

全局变量

int gl_PrimitiveIDIn

当前输入的图元,在当前绘制命令生成的图元序列中的索引

int gl_InvocationID

当前的几何着色器实例化调用索引,关于几何着色器实例化,见后文的语法讲解

gl_PrimitiveIDIn

若管线有执行细分,gl_PrimitiveIDIn的数值为先前在细分求值着色器中写入的值。
若管线没有执行细分,gl_PrimitiveIDIn的数值为本次绘制命令中,顶点着色器依序生成的图元序列中的索引,且不受实例化绘制的影响(即同一图元的不同实例具有相同的gl_PrimitiveIDIn)。若想要在实例化绘制中区分所有输入到几何着色器的图元,可以将顶点着色器中的gl_InstanceIndex输出到几何着色器。

几何着色器的内置输出

用GLSL编写的顶点着色器有以下内置输出:

gl_PerVertex {
    vec4 gl_Position;
    float gl_PointSize;
    float gl_ClipDistance[];
    float gl_CullDistance[];
};

int gl_PrimitiveID;
int gl_Layer;
int gl_ViewportIndex;
  • 作为内置输出的gl_PerVertex块是不具名的实例,其使用同在顶点着色器中一致(当全局变量用即可)。

全局变量

int gl_PrimitiveID

图元的索引

int gl_Layer

若进行多层渲染,指定要渲染到多层帧缓冲的哪个图层

int gl_ViewportIndex

若开启了多视口,指定要渲染到哪个视口

虽然gl_PrimitiveID、gl_Layer、gl_ViewportIndex不在gl_PerVertex块中,但每一个顶点相应的数据也都具有各自的值。
若同一图元的不同顶点具有不同的gl_Layer或gl_ViewportIndex数值,采用哪个顶点的数值为实现特定,因此应确保同一图元各顶点具有相同的gl_Layer或gl_ViewportIndex。

gl_PrimitiveID

对于一个有几何着色器的管线,若片段着色器中需要使用gl_PrimitiveID,则必须在几何着色器中写入gl_PrimitiveID(即它不会自动取得某个数值)。
之后片段着色器中接收到的gl_PrimitiveID为激发顶点所具有的数值,即,即便给同一图元的不同顶点指定不同的gl_PrimitiveID,也只有激发顶点具有的数值有意义。关于激发顶点,请自行参阅Vulkan官方标准中的图示

几何着色器的特殊语法

下面给出对于Vulkan默认可使用的几何着色器特有的GLSL语法(略去如今已不推荐使用的transform feedback相关语法)。

指定输入的图元类型

layout(图元类型) in;

填写的图元类型

相当于何种图元拓扑类型(省略VK_PRIMITIVE_TOPOLOGY_前缀)

顶点数

points

POINT_LIST

1

lines

LINE_LISTLINE_STRIP

2

lines_adjacency

LINE_LIST_WITH_ADJACENCYLINE_STRIP_WITH_ADJACENCY

4

triangles

TRIANGLE_LISTTRIANGLE_STRIPTRIANGLE_FAN

3

triangles_adjacency

TRIANGLE_LIST_WITH_ADJACENCYTRIANGLE_STRIP_WITH_ADJACENCY

6

先前的着色器阶段输出的顶点数据会按照上表的顶点数被变为定长数组,输入到几何着色器。
因此内置输入gl_in是数组,而自定义输入的数据也必须定义为数组(就算图元类型为点使得顶点数为1也是如此),在定义时数组长度缺省。
例:

//指定输入的图元类型为三角形
layout(triangles) in;

//则这里的i_Colors是包含三个元素的数组,由先前的顶点/细分求值着色器传递而来,对应一个三角形图元中的三个顶点的数据
layout(location = 0) in vec4 i_Colors[];

指定输出的图元类型

layout(图元类型, max_vertices = 最大顶点数) out;

填写的图元类型

相当于何种图元拓扑类型(省略VK_PRIMITIVE_TOPOLOGY_前缀)

points

POINT_LIST

line_strip

LINE_STRIP

triangle_strip

TRIANGLE_STRIP

  • 这里指定的是最大顶点数,实际在一次几何着色器调用中生成的顶点数量可以小于该值。

  • 最大顶点数必须是几何着色器内的编译期常数(不得为可特化常量),且不得超过VkPhysicalDeviceLimits::maxGeometryOutputVertices(标准规定该值至少为256)。

  • 注意到这里的拓扑类型line_strip和triangle_strip会使得连续的线/三角形共用顶点。如果你有必要使用gl_PrimitiveID,注意区分激发顶点;至于gl_Layer和gl_ViewportIndex,建议同一几何着色器调用中不要把图元渲染到不同图层或视口上(似乎也不存在这么干的必要)。

生成图元

使用GLSL的内置函数EmitVertex()提交一个顶点的顶点数据。
使用GLSL的内置函数EndPrimitive()结束当前图元。
使用细则见后文示例。

几何着色器实例化

可以在几何着色器中对同一图元的顶点数据重复执行多次着色器调用,在不同的调用中可以根据gl_InvocationID输出不同的顶点数据,这一做法称之为几何着色器的实例化。

指定调用次数的语法为:

layout(invocations = 调用次数) in;

图元类型和实例化调用次数可以写在一个layout中:

layout(图元类型, invocations = 调用次数) in;

这与实例化绘制是各自独立的功能,在图元绘制数量上能与实例化绘制产生相乘效果。
由于这里的调用次数是几何着色器内的编译期常数,且不得超过VkPhysicalDeviceLimits::maxGeometryShaderInvocations(至少为32),因此如果你仅仅是想要绘制同一图元多次而没有什么更复杂的要求的话,实例化绘制更加灵活。

示例

下面修改自一组我用于烘焙立方体贴图的顶点着色器和几何着色器,略去片段着色器。
绘制时使用的图元拓扑类型为VK_PRIMITIVE_TOPOLOGY_POINT_LIST,对应的绘制命令为vkCmdDraw(commandBuffer, 1, 6, 0, 0)

#version 460
#pragma shader_stage(vertex)

//无输入,只输出
layout(location = 0) out int i_Layer;

void main() {
    i_Layer = gl_InstanceIndex; //由于片段着色器中获取不到gl_InstanceIndex,由顶点着色器获取后传入几何着色器
}
#version 460
#pragma shader_stage(geometry)

vec2 positions[4] = {
    { -1, -1 },
    { -1,  1 },
    {  1, -1 },
    {  1,  1 }
};
vec3 texCoords[24] = {
    /*...*/
};

//指定输入点
layout(points) in;
//指定输出由四个顶点构成的triangle_strip,旨在绘制占满一整个图像的正方形
layout(triangle_strip, max_vertices = 4) out;

//输入数据,由顶点着色器传递而来,因为输入图元类型为点,i_Layers的实际数组长度为1
layout(location = 0) in int i_Layers[];
//输出采样贴图用的坐标(涉及采样天空盒,故类型为vec3,不明白的话不必在意)
layout(location = 0) out vec3 o_UVS;

void main() {
    for (uint i = 0; i < 4; i++) {
        gl_Position = vec4(positions[i], 0, 1);
        gl_Layer = i_Layers[0];
        o_UVS = texCoords[i + gl_Layer * 4];
        EmitVertex();
    }
    EndPrimitive();
}
  • 这是一组进行多层渲染的着色器,将gl_InstanceIndex的值赋值给gl_Layer以确定渲染到的帧缓冲图层。

关于输入输出的语法都讲解过了,重点看下几何着色器的主函数,循环部分:

for (uint i = 0; i < 4; i++) {
    gl_Position = vec4(positions[i], 0, 1);
    gl_Layer = i_Layers[0];
    o_UVS = texCoords[i + gl_Layer * 4];
    EmitVertex();
}

可以看到,在调用EmitVertex()提交单个顶点的顶点数据之前,写入的三个变量gl_Position、gl_Layer、o_UVS都不是数组,也就是说这些输出变量是不同的顶点共用的。
留意到gl_Layer = i_Layers[0];,由于i_Layers[0]是定值,你大概会疑惑“这岂不是重复赋值了吗!?”,注意:
对使用EmitVertex()提交的每一个顶点的数据都要进行赋值,即调用EmitVertex()后,不保证保留先前赋值给任何输出变量的数据。

接下来EndPrimitive()没什么好说的,只不过在调用EndPrimitive()之后,主函数仍没有结束,你可以接着干别的事:

void main() {
    for (uint i = 0; i < 4; i++) {
        /*...*/
    }
    EndPrimitive();
    /*这里可以接着写东西,比如写入storage缓冲区等*/
}

上面的代码为了演示在几何着色器中获取实例索引gl_InstanceIndex的方法而刻意多写了几行。
如果你比较敏锐的话,可能意识到了,上述两个着色器的代码能被进一步简化:

#version 460
#pragma shader_stage(vertex)

//不干任何事的顶点着色器,滅茶苦茶sexy!
void main() {}
#version 460
#pragma shader_stage(geometry)

vec2 positions[4] = {
    /*...*/
};
vec3 texCoords[24] = {
    /*...*/
};

layout(points) in;
layout(triangle_strip, max_vertices = 4) out;
layout(location = 0) out vec3 o_UVS;

void main() {
    for (uint i = 0; i < 4; i++) {
        gl_Position = vec4(positions[i], 0, 1);

        /*变更*/gl_Layer = gl_PrimitiveIDIn;

        o_UVS = texCoords[i + gl_Layer * 4];
        EmitVertex();
    }
    EndPrimitive();
}
  • 对应的绘制命令为vkCmdDraw(commandBuffer, 6, 1, 0, 0),相应地将图元索引gl_PrimitiveIDIn赋值给gl_Layer以确定渲染到的帧缓冲图层。

其实对于上述这种不需要输入任何顶点数据的多层渲染,还有一种确定gl_Layer的方法,对应的绘制命令亦有所不同,这里就留作一道简单的思考题了。

使用几何着色器进行多层渲染的具体用例见//TODO Ch9-1 将ERP图像转到立方体贴图。