细分着色器的魔法

  LearnOpenGL教程中缺失了细分着色器部分的教程,在学习了《OpenGL编程指南》这本书细分着色器章节以后,将自己对细分着色器的一些理解总结成此文。

细分着色器

  细分着色器(Tessellation Shader)是OpenGL中的一个重要功能,它可以用于动态细分几何形状。在传统的渲染管线中,几何形状是由顶点和三角形构成的。而细分着色器可以在渲染过程中对需要渲染的几何形状进行细分,从而创建更加细致和复杂的几何细节。

简介

  细分着色器的主要作用是将输入的几何形状(面片,patch)细分为更多的顶点和三角形,以便在渲染过程中实现更高的细节级别。它通常与细分控制着色器(Tessellation Control Shader)和细分计算着色器(Tessellation Evaluation Shader)一起使用。
  细分控制着色器用于控制细分过程中的细节级别和细分模式。细分评估着色器则负责计算细分后的顶点位置,并生成新的几何形状。通过使用细分着色器,可以实现一些强大的效果,如曲面细分、地形细分、细分细节的动态调整等。它可以使渲染的几何形状更加真实、细腻,并提供更高的细节级别,以增强视觉效果和真实感。
  细分着色器是在OpenGL 4.0版本中引入的,并且需要支持OpenGL 4.0或更高版本的显卡和驱动才能使用。它是一个强大而灵活的工具,为开发人员提供了更多控制几何形状细节的能力,使得渲染效果更加逼真和精细。

细分控制着色器

  细分控制着色器主要完成两件事:
  1.设置细分的级别,以控制生成图元的操作。控制细分级别的为内置的变量gl_TessLevelInnergl_TessLesslOuter
  2.对输入面片做一些计算,然后向细分着色器传递顶点。

输入与输出

  我们从细分控制着色器的输入和输出来理解细分控制着色器这一阶段要做的事情。
  细分控制着色器最重要的输入是一个结构体数组,其元素定义如下:

in gl_PreVertex{
    vec4 gl_Position;
    float gl_PointSize;
    float gl_ClipDistance[];
    float gl_CullDistance[];
} gl_in[gl_PatchVerticesIn];

  开启细分着色器以后,绘制命令中的类型为GL_PATCHES。在调用绘制命令之前,我们还需要使用glPatchParameteri指定每个Patches的顶点数量。其中,我们指定的每个面片中顶点的数量就确定了gl_in数组的大小。比如,我们使用glPatchParameteri(GL_PATCH_VERTICES, 4);指定每个面片的顶点数量为4,那么细分控制着色器输入的顶点数组gl_in的大小就是4。
  细分控制着色器的输出也是和输入一样的结构体,只不过其数量通过细分控制着色器的out布局限定符号layout来设置,比如下面的布局指明了细分控制着色器向下传递的数组数量为4个:

layout(vertices = 4) out;

  需要注意的是,细分控制着色器输出的顶点数量和输入的顶点数量没有必然关系。你可以指定每个patch的顶点数量为1,但是在细分着色器中通过计算生成3个顶点向下传递,你只需要使用layout布局限定符号指定即可。

细分级别

  细分级别用于控制细分的方法,支持三种不同的细分域:四边形、三角形或者等值线集合。需要理解的是,细分域其实只是指定了细分坐标的的生成形式,并不会影响细分控制着色器向下传递的顶点数量。重申一遍,细分着色器输出的顶点数量是由layout布局限定符确定的。细分的总量是通过两组数据来控制的,内侧和外侧细分层级。外侧细分层级负责控制细分领域的周长,保存在一个声明为4个元素的数组gl_TessLevelOuter中。类似地,内侧细分层级设置的是细分区域的内部划分方式,它保存在一个长度为2的数组gl_TesslevelInner中。
  在不同的细分域中,细分层级的控制数组中实际用于控制细分数量的值可能不同(三角形外周只有三个边,所以外侧细分层级的数值并不能完全使用)。细分层级对于细分效果的影响可以参考OpenGL Wiki的相关说明。这里以四边形细分域为例进行简单的说明,如下图所示:

  上图的效果为以下细分层级设置的结果:

gl_TessLevelInner[0] = 3.0;  // IL-0方向的划分段数
gl_TessLevelInner[1] = 4.0;  // IL-1方向的划分段数

gl_TessLevelOuter[0] = 2.0;  // OL-0方向的划分段数
gl_TessLevelOuter[1] = 3.0;  // OL-1方向的划分段数
gl_TessLevelOuter[2] = 2.0;  // OL-2方向的划分段数
gl_TessLevelOuter[3] = 5.0;  // OL-3方向的划分段数

  三角形和等值线的细分域表示上略有区别,但是含义和表现基本类似。
  提醒:《OpenGL编程指南》中关于gl_TessLevelInner的说明和wiki上不同,代码的执行结果和wiki的解释一致,请以wiki为准。

细分控制举例

  为了进一步说明细分控制着色器的输入和输出之间的关系,这里通过一个简单的例子进行演示。另外,也对上一小节中设置的细分层级设置的效果进行实际的展示。
  首先,我们定义每个面片的顶点数量为1,然后仅仅绘制一个patch,如下所示(省去了部分其他代码):

// 要绘制的顶点
float vertices[] = {
//     ---- 位置 ----  
    0.0f, 0.0f, 0.0f,
};

glPatchParameteri(GL_PATCH_VERTICES, 1);
glDrawArrays(GL_PATCHES, 0, 1);

  示例使用的顶点着色器和片段着色器非常简单,顶点着色器只读取顶点向下传递,片段着色器只给片段设置一个固定的颜色值。我们将重点放在细分控制着色器上,细分计算着色器也可以暂时忽略不计,计算过程只是计算出细分的坐标点而已。在细分控制着色器中,我们设置了向下传递的顶点数量为4,通过计算将patch中的一个顶点扩展成了四个顶点向下传递;指定了上一小节中介绍的细分等级设置。在细分计算着色器中,使用传入的四个顶点和细分坐标插值计算得到所有细分的顶点并向后传递。细分着色器的内容如下所示:
  细分控制着色器:

#version 430 core

layout(vertices = 4) out;

void main()
{
    // 设置细分级别
    gl_TessLevelInner[0] = 3.0;
    gl_TessLevelInner[1] = 4.0;

    gl_TessLevelOuter[0] = 2.0;
    gl_TessLevelOuter[1] = 3.0;
    gl_TessLevelOuter[2] = 2.0;
    gl_TessLevelOuter[3] = 5.0;
    // 输出顶点
    float xOffset[] = float[4](-0.5f,  0.5f, 0.5f, -0.5f);
    float yOffset[] = float[4](-0.5f, -0.5f, 0.5f,  0.5f);

    vec4 position = gl_in[0].gl_Position;
    gl_out[gl_InvocationID].gl_Position = vec4(position.x + xOffset[gl_InvocationID], 
                                               position.y + yOffset[gl_InvocationID], 
                                               position.z, position.w);
}

  细分计算着色器:

#version 430 core

layout(quads, equal_spacing, ccw) in;

void main()
{
    float u = gl_TessCoord.x;
    float um = 1 - u;
    float v = gl_TessCoord.y;
    float vm = 1 - v;

    // 插值的方法计算映射到顶点的坐标系中
    gl_Position = um * vm * gl_in[0].gl_Position 
                 + u * vm * gl_in[1].gl_Position 
                 + u * v  * gl_in[2].gl_Position
                 + um * v * gl_in[3].gl_Position;
}

  细分以后的结果如下:

细分计算着色器

  细分计算着色器作为细分着色器的第二个也是最后一个阶段,主要的任务是执行细分顶点的计算,将计算得到的细分顶点向后传递。

输入与输出

  细分计算着色器值得关注的输入有两个部分组成:细分控制着色器输出的顶点数组(整个数组gl_in)和细分坐标。这意味着,我们可以访问到细分控制着色器中传入的顶点数组中的所有数据,并根据细分坐标进行一些计算。其中,由细分控制着色器传入的顶点数组不由细分计算着色器控制,而细分坐标则通过在细分计算着色器中指定layout布局设定。细分坐标的生成主要有三种类型,如下表所示:

图元类型 描述 细分坐标形式
quads 单位块上的一个四边形域 (u, v)对的形式,u和v的范围从0~1
triangles 使用中心坐标的三角形 (a, b, c)坐标形式,a、b和c的范围均为0~1,且有a+b+c=1
isolines 一系列穿过单位块的线段集合 (u, v)对的形式,u的范围从0~1,v的范围从0~接近于1的数值

  
  其次,还需要设置图元面朝向、细分坐标的间隔和细分后的图元类型。本小节主要介绍输入和输出的内容,具体设置值的含义请参考OpenGL Wiki。以下为layout布局的一种设置方法:

// 表示细分域为单位四边形,使用等间隔划分,生成的顶点序列为逆时针方向,向后输出的图元为点(默认为三角面)
layout(quads, equal_spacing, ccw, point_mode) in;

  细分计算着色器的输出便是将计算好的细分顶点向下传递,其定义如下:

out gl_PreVertex{
    vec4 gl_Position;
    float gl_PointSize;
    float gl_ClipDistance[];
    float gl_CullDistance[];
};

绘制网格

  对于细分着色器的细分等级控制,我们可以看到OpenGL Wiki中给出了四边形细分域的网格状细分设置。那么我们通过设置细分控制着色器的所有细分等级为同一个值实现网格的绘制。我们依然延用细分控制举例中的数据流进行绘制,只需要修改我们的细分控制着色器即可,修改后的细分控制着色器如下所示:

#version 430 core

layout(vertices = 4) out;

void main()
{
    // 设置细分级别
    gl_TessLevelInner[0] = 5.0;
    gl_TessLevelInner[1] = 5.0;

    gl_TessLevelOuter[0] = 5.0;
    gl_TessLevelOuter[1] = 5.0;
    gl_TessLevelOuter[2] = 5.0;
    gl_TessLevelOuter[3] = 5.0;
    // 输出顶点
    float xOffset[] = float[4](-0.5f,  0.5f, 0.5f, -0.5f);
    float yOffset[] = float[4](-0.5f, -0.5f, 0.5f,  0.5f);

    vec4 position = gl_in[0].gl_Position;
    gl_out[gl_InvocationID].gl_Position = vec4(position.x + xOffset[gl_InvocationID], 
                                               position.y + yOffset[gl_InvocationID], 
                                               position.z, position.w);
}

  绘制出的细分效果:

绘制柱面

  等分的坐标让人很容易想到圆形顶点生成时用到的角度等分方法,如果我们提供更多的参数,可以利用等分的特性绘制一个柱面。

绘制命令

  从二维到三维,我们想知道绘制的结果是否正确,我们需要变换视角来查看绘制的几何体。因此,这里给出整个的绘制程序代码。其中,顶点依然只有一个,绘制时每个patch的顶点个数依然设置为1。其次,使用LearnOpenGL中的相机类控制视角,使用改进后的shaderProgram类加载和编译着色器。另外,为了让绘制的结果更加明显,我使用了黑色的背景颜色。如果你想使用这段绘制程序代码,记得将着色器源码的文件路径修改为自己工程中的路径。完整的绘制程序代码如下:

#include <glad/glad.h>
#include <GLFW/glfw3.h>

#define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h>

#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>

#include "shader_program.h"
#include "camera.h"

#include <iostream>
#include <vector>

void framebuffer_size_callback(GLFWwindow* window, int width, int height);
void mouse_callback(GLFWwindow* window, double xpos, double ypos);
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset);
void processInput(GLFWwindow *window);

// settings
const unsigned int SCR_WIDTH = 800;
const unsigned int SCR_HEIGHT = 600;

// camera
Camera camera(glm::vec3(0.0f, 0.0f, 5.0f));
float lastX = SCR_WIDTH / 2.0f;
float lastY = SCR_HEIGHT / 2.0f;
bool firstMouse = true;

// timing
float deltaTime = 0.0f; // time between current frame and last frame
float lastFrame = 0.0f;

int main()
{
    // glfw: initialize and configure
    // ------------------------------
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

#ifdef __APPLE__
    glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif

    // glfw window creation
    // --------------------
    GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "LearnOpenGL", NULL, NULL);
    if (window == NULL)
    {
        std::cout << "Failed to create GLFW window" << std::endl;
        glfwTerminate();
        return -1;
    }
    glfwMakeContextCurrent(window);
    glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
    glfwSetCursorPosCallback(window, mouse_callback);
    glfwSetScrollCallback(window, scroll_callback);

    // tell GLFW to capture our mouse
    glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);

    // glad: load all OpenGL function pointers
    // ---------------------------------------
    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
    {
        std::cout << "Failed to initialize GLAD" << std::endl;
        return -1;
    }

    // configure global opengl state
    // -----------------------------
    glEnable(GL_DEPTH_TEST);

    // build and compile our shader zprogram
    // ------------------------------------
    using ShaderSourcePair = ShaderProgram::ShaderSourcePair;
    using ShaderType = ShaderProgram::ShaderType;
    ShaderProgram shaderProgram = ShaderProgram(
        ShaderSourcePair{ShaderType::VERTEX, "path/to/vertexShder"},
        ShaderSourcePair{ShaderType::TESSCONTROL, "path/to/tessControlShder"},
        ShaderSourcePair{ShaderType::TESSEVALUATION, "path/to/tessEvaluationShder"},
        ShaderSourcePair{ShaderType::FRAGMENT, "path/to/fragmentShder"}
    );

    float vertices[] = {
        0.0f, 0.0f, 0.0f
    };

    unsigned int VBO, VAO;
    glGenVertexArrays(1, &VAO);
    glGenBuffers(1, &VBO);

    glBindVertexArray(VAO);
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
    // position attribute
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    glBindBuffer(GL_ARRAY_BUFFER, 0);
    glEnableVertexAttribArray(0);

    // 开启线框模式
    glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);

    // render loop
    // -----------
    while (!glfwWindowShouldClose(window))
    {
        // per-frame time logic
        // --------------------
        float currentFrame = static_cast<float>(glfwGetTime());
        deltaTime = currentFrame - lastFrame;
        lastFrame = currentFrame;

        // input
        // -----
        processInput(window);

        // render
        // ------
        glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); 

        // 绘制cube
        shaderProgram.use();
        glm::mat4 projection = glm::perspective(glm::radians(camera.Zoom), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
        glm::mat4 view = camera.GetViewMatrix();

        shaderProgram.setMat4("projection", projection);
        shaderProgram.setMat4("view", view);
        glm::mat4 model = glm::mat4(1.0f);
        model = glm::scale(model, glm::vec3(0.3f));
        shaderProgram.setMat4("model", model);

        glPatchParameteri(GL_PATCH_VERTICES, 1);

        glBindVertexArray(VAO);
        glDrawArrays(GL_PATCHES, 0, 1);

        // glfw: swap buffers and poll IO events (keys pressed/released, mouse moved etc.)
        // -------------------------------------------------------------------------------
        glfwSwapBuffers(window);
        glfwPollEvents();
    }

    // optional: de-allocate all resources once they've outlived their purpose:
    // ------------------------------------------------------------------------
    glDeleteVertexArrays(1, &VAO);
    glDeleteBuffers(1, &VBO);

    // glfw: terminate, clearing all previously allocated GLFW resources.
    // ------------------------------------------------------------------
    glfwTerminate();
    return 0;
}

// process all input: query GLFW whether relevant keys are pressed/released this frame and react accordingly
// ---------------------------------------------------------------------------------------------------------
void processInput(GLFWwindow *window)
{
    if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
        glfwSetWindowShouldClose(window, true);
    bool isDoubleSpeed = false;
    if (glfwGetKey(window, GLFW_KEY_LEFT_SHIFT) == GLFW_PRESS)
        isDoubleSpeed = true;
    if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
        camera.ProcessKeyboard(FORWARD, deltaTime, isDoubleSpeed);
    if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
        camera.ProcessKeyboard(BACKWARD, deltaTime, isDoubleSpeed);
    if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
        camera.ProcessKeyboard(LEFT, deltaTime, isDoubleSpeed);
    if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
        camera.ProcessKeyboard(RIGHT, deltaTime, isDoubleSpeed);
}

// glfw: whenever the window size changed (by OS or user resize) this callback function executes
// ---------------------------------------------------------------------------------------------
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
    // make sure the viewport matches the new window dimensions; note that width and 
    // height will be significantly larger than specified on retina displays.
    glViewport(0, 0, width, height);
}

// glfw: whenever the mouse moves, this callback is called
// -------------------------------------------------------
void mouse_callback(GLFWwindow* window, double xposIn, double yposIn)
{
    float xpos = static_cast<float>(xposIn);
    float ypos = static_cast<float>(yposIn);

    if (firstMouse)
    {
        lastX = xpos;
        lastY = ypos;
        firstMouse = false;
    }

    float xoffset = xpos - lastX;
    float yoffset = lastY - ypos; // reversed since y-coordinates go from bottom to top

    lastX = xpos;
    lastY = ypos;

    camera.ProcessMouseMovement(xoffset, yoffset);
}

// glfw: whenever the mouse scroll wheel scrolls, this callback is called
// ----------------------------------------------------------------------
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
    camera.ProcessMouseScroll(static_cast<float>(yoffset));
}

  后续的章节中,我们依然会延用这个绘制程序。

细分控制着色器

  细分控制着色器中,我们希望能在圆柱面的界面上进行细分,而在轴线上尽可能不做细分或者少做细分。最终得到的细分控制结果如下:

#version 430 core

layout(vertices = 1) out;

void main()
{
    // 硬编码设置细分级别
    gl_TessLevelInner[0] = 30.0;
    gl_TessLevelInner[1] = 1.0;

    gl_TessLevelOuter[0] = 1.0;
    gl_TessLevelOuter[1] = 30.0;
    gl_TessLevelOuter[2] = 1.0;
    gl_TessLevelOuter[3] = 30.0;
    // 输出顶点
    gl_out[gl_InvocationID].gl_Position = gl_in[0].gl_Position;
}

  细分控制着色器中,我们将四边形的两个维度分别进行细分,其中一个细分段数多,另一个细分段数少。

细分计算着色器

  在细分计算着色器中,我们通过等分的细分坐标作为计算顶点的依据。注意:细分坐标是归一化的按照细分等级天然等分的二维坐标,大有可为。细分计算着色器中,将分段较多的那一个维度的细分段作为圆周角度等分的依据;将分段较少的那一个维度的细分段作为轴线上顶点的生成依据(我们不需要太多顶点和三角面)。其中,我是用硬编码指定了轴线,你可以通过任何其他可用的方式来形成轴线的起点和终点。具体的计算过程如下:

#version 430 core

layout(quads, equal_spacing, ccw) in;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    // 定义常量
    const float PI = 3.1415926;
    const float radius = 1.0;
    // 细分坐标(u,v)在0到1之间,
    float u = gl_TessCoord.x; // 横向划分的顶点,划分段更多,作为圆周的划分
    float v = gl_TessCoord.y; // 纵向划分的顶点,划分较少,作为轴向的划分

    // 将一个顶点扩展成一个轴,这里硬编码一个x轴方向上从-5到5的轴
    float offset[] = float[2](-5, 5);
    vec4 position = gl_in[0].gl_Position;
    vec3 startPt= vec3(position.x + offset[0], position.y, position.z);
    vec3 endPt= vec3(position.x + offset[1], position.y, position.z);
    vec3 dir = endPt - startPt;

    // 圆形横截面的x轴和y轴, 直接硬编码
    float angleRadians = u * 2.0f * PI;
    vec3 baseXdir = vec3(0.0, 1.0, 0.0);
    vec3 baseYdir = vec3(0.0, 0.0, 1.0);
    // 以较少的细分等级计算,轴线上的点作为该细分等级的圆心
    vec3 originPoint = startPt + v * dir;
    // 以圆心为原点,以另圆周的细分维度计算圆上的点
    vec4 point = vec4(originPoint + radius * cos(angleRadians) * baseXdir + radius * sin(angleRadians) * baseYdir, 1.0);
    // 求解最终的位置
    gl_Position = projection * view * model * point;
}

  其中,我们先沿着轴线计算得到轴线上的顶点,然后在使用圆周的细分段生成当前截面上的顶点,最后对计算得到的坐标进行矩阵变换。
  最终,绘制出的圆柱面如下所示:

绘制球体

  通过解析绘制柱面的过程,我们不难发现计算细分顶点时我使用了硬编码的半径。更进一步,如果这个半径是可变的呢?比如,在两端时为0,中间为半径长度,平滑的过渡就使得柱面变成了球面!想要实现这个过程,我们只需要对细分着色器做很简单的修改即可。

细分控制着色器

  对于细分控制着色器而言,绘制球体我们需要两个维度上都能够足够多的细分段。因此,我们只需要修改细分控制着色器的细分等级即可,如下所示:

#version 430 core

layout(vertices = 1) out;

void main()
{
    // 硬编码设置细分级别
    gl_TessLevelInner[0] = 30.0;
    gl_TessLevelInner[1] = 40.0;

    gl_TessLevelOuter[0] = 40.0;
    gl_TessLevelOuter[1] = 30.0;
    gl_TessLevelOuter[2] = 40.0;
    gl_TessLevelOuter[3] = 30.0;
    // 输出顶点
    gl_out[gl_InvocationID].gl_Position = gl_in[0].gl_Position;
}

细分计算着色器

  细分计算着色器中,我们只需要在生成截面上圆周顶点的时候使用当前截面的半径计算即可,细分计算着色器的内容如下:

#version 430 core

layout(quads, equal_spacing, ccw) in;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    // 定义常量
    const float PI = 3.1415926;
    const float radius = 4.0;
    // 细分坐标(u,v)在0到1之间,
    float u = gl_TessCoord.x;
    float v = gl_TessCoord.y;

    // 将一个顶点扩展成一个轴,这里硬编码一个x轴方向上从-radius到radius的轴
    vec4 position = gl_in[0].gl_Position;
    vec3 startPt= vec3(position.x - radius, position.y, position.z);
    vec3 endPt= vec3(position.x + radius, position.y, position.z);
    vec3 dir = endPt - startPt;

    // 圆形横截面的x轴和y轴, 直接硬编码
    float angleRadians = u * 2.0f * PI;
    vec3 baseXdir = vec3(0.0, 1.0, 0.0);
    vec3 baseYdir = vec3(0.0, 0.0, 1.0);
    // 以较少的细分等级计算,轴线上的点作为该细分等级的圆心
    vec3 originPoint = startPt + v * dir;
    // 计算当前位置截面上的半径,球体半径、当前轴线上的点到球心线段、当前截面上的半径组成了一个直角三角形
    // 球心在(0,0,0)处,所有简单直接用轴线上的originPoint坐标进行计算了
    float currentRadius = sqrt(radius * radius - dot(originPoint, originPoint));
    // 以圆心为原点,以另圆周的细分维度计算圆上的点
    vec4 point = vec4(originPoint + currentRadius * cos(angleRadians) * baseXdir + currentRadius * sin(angleRadians) * baseYdir, 1.0);
    // 求解最终的位置
    gl_Position = projection * view * model * point;
}

  最终,绘制出的球面如下所示:

绘制箭头

  不难发现,对圆周面上半径的控制就能够绘制不同的效果,那如果控制半径的是一个分段函数呢?那我们就可以获得更多有趣好玩的效果!一个箭头的前部分是一个圆锥,后部分是一个圆柱面,使用分段函数控制半径就能绘制一个箭头。

细分控制着色器

  细分着色器完全可以复用绘制球体的细分控制着色器。当然,你也可以适当调整细分等级。

细分计算着色器

  细分计算着色器的修改相对更加简单一些,只需要重新计算箭头部分的半径即可。细分计算着色器如下:

#version 430 core

layout(quads, equal_spacing, ccw) in;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    // 定义常量
    const float PI = 3.1415926;
    const float radius = 1.0;
    // 细分坐标(u,v)在0到1之间,
    float u = gl_TessCoord.x;
    float v = gl_TessCoord.y;

    // 将一个顶点扩展成一个轴,这里硬编码一个x轴方向上从-20到20的轴
    vec4 position = gl_in[0].gl_Position;
    vec3 startPt= vec3(position.x - 20.0, position.y, position.z);
    vec3 endPt= vec3(position.x + 20.0, position.y, position.z);
    vec3 dir = endPt - startPt;

    // 圆形横截面的x轴和y轴, 直接硬编码
    float angleRadians = u * 2.0f * PI;
    vec3 baseXdir = vec3(0.0, 1.0, 0.0);
    vec3 baseYdir = vec3(0.0, 0.0, 1.0);

    float arrowRadio = 0.2;
    vec3 originPoint = startPt + v * dir;
    float currentRadius = radius;
    // 计算箭头部分的半径
    if(v < arrowRadio){
        currentRadius = 2 * radius / arrowRadio * v;
    }
    // 以圆心为原点,以另圆周的细分维度计算圆上的点
    vec4 point = vec4(originPoint + currentRadius * cos(angleRadians) * baseXdir + currentRadius * sin(angleRadians) * baseYdir, 1.0);
    // 求解最终的位置
    gl_Position = projection * view * model * point;
}

  最终,绘制出的箭头如下所示:

总结

  综上,我们了解了细分着色器的数据传递过程和工作原理。同时,我们发现使用划分方式生成顶点的几何体都可以利用细分着色器的等分的效果进行顶点的生成。其次,通过控制不同的细分等级获取不同的细分坐标,从而进行复杂的顶点生成或者计算。然后,我们还可以通过引入更多的参数,实现不同的绘制效果,比如:引入相机参数动态调整细分等级等。
  需要阐明的是,本文旨在介绍自己对细分着色器的理解,并使用最简单粗暴(硬编码、部分计算放到细分计算着色器中执行等)的方式介绍一些细分着色器使用的方法。在实际的工程应用中,我们应当考虑是否使用细分着色器、细分着色器不同阶段执行的次数和传递数据的效率,从而权衡不同数据生成的时机和计算过程发生的阶段。


当珍惜每一片时光~