上一节我们编写了一个完整的,从编写顶点文件、顶点着色器、片段着色器、着色器程序,到启动项目。但是仍然有一个问题:着色器文件、片段着色器文件的源文件都是我们自己在main.cpp文件里硬编码的,这样就导致我们的着色器程序的可读性非常差,并且编辑起来会更困难。所以我们在这篇文章里会将着色器文件独立出来,并且新建一个类来管理这个着色器文件。

独立出着色器文件

        首先,我们先在项目路径下新建一个文件夹,用于存放着色器文件,并且在该文件夹下创建两个文本文件:vertexShader.txt,fragmentShader.txt
     

        在创建好文件之后,将我们之前写好的顶点着色器和片段着色器源代码复制粘贴到相应的.txt文件中,下面两段代码中有部分内容在上一节没有介绍到,不用着急,等创建好着色器类后会一一解释:

// 顶点着色器源代码
#version 330 core
layout(loctaion = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
out vec3 outColor;
void main(){
    gl_Position = vec4(aPos, 1.0f);
    outColor = aColor;
}

// 片段着色器源代码
#version 330 core
in vec3 outColor;
out vec3 FragColor;
uniform vec3 timeColor;
void main(){
    FragColor = vec4(outColor, 1.0f);
    // FragColor = vec4(timeColor, 1.0f);
}

        着色器文件已经写好,那么接下来是将我们的着色器在主程序中被使用,这就不免要用到文件读取相关函数、方法,所以这里我们编写一个着色器类来管理所有着色器,我们需要在Visual Studio 2022里的项目路径下新建头文件:shader.h

使用类管理着色器

        Shader类的方法和变量定义,在下图代码中展示:

#ifndef SHADER_H
#define SHADER_H

#include <glad/glad.h>
#include <string>
#include <fstream>
#include <sstream>
#include <iostream>

class Shader
{
public:
    // 记录着色器程序对象绑定的id
    unsigned int programId;
    // 顶点着色器文件源码
    const std::string vertexPath;
    // 片段着色器文件源码
    const std::string fragmentPath;
    // 类的构造方法
    Shader(const std::string vertexPath, const std::string fragmentPath);
    // 初始化着色器
    void shaderInit();
    // 启用着色器程序
    void useProgram();
    // 读取指定路径下的文件
    std::string readFileToString(const std::string path);

private:
    // 检查着色器编译时的错误
    void checkCompileErrors(unsigned int shader, const std::string& type);
};

...方法实现

#endif

        接下来我们一一介绍每个方法的实现,但是不会细节解释每一个语句的含义,只会在关键地方进行提示。

        首先是构造函数,其接受两个参数:顶点、片段着色器源码路径,并将其保存在变量中。由于路径的变量类型是const,而const成员不能在构造函数体内赋值,所以只能使用初始化列表进行赋值:

Shader::Shader(const std::string vertexPath, const std::string fragmentPath)
    :vertexPath(vertexPath), fragmentPath(fragmentPath), programId(0){
}

        接着是读取指定路径下文件的函数,它接受一个const string类型的路径参数,并返回一个string类型的值用来存储文件内容:

std::string Shader::readFileToString(const std::string path) {
    // 打开文件
    std::ifstream file(path);
    // 检查文件是否成功打开
    if (!file.is_open()) {
        throw std::runtime_error("无法打开文件:" + path);
    }
    
    // 创建字符串流缓冲区
    std::stringstream buffer;
    // 将文件内容读取到缓冲区
    buffer << file.rdbuf();
    // 关闭文件
    file.close();

    // 返回字符串内容
    return buffer.str();
}

        然后是检查着色器编译时错误的函数,

void Shader::checkCompileErrors(unsigned int shader, const std::string& type) {
    int success;                    // 用于存储检查结果
    char infoLog[1024];             // 用于存储错误信息

    if (type != "PROGRAM") {        // 检查着色器编译
        // 获取着色器编译状态
        glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
        if (!success) {             // 如果编译失败
            // 获取详细的错误信息
            glGetShaderInfoLog(shader, 1024, NULL, infoLog);
            // 输出错误信息
            std::cerr << "着色器编译错误 (" << type << "):\n" << infoLog << std::endl;
        }
    }
    else {                          // 检查程序链接
        // 获取程序链接状态
        glGetProgramiv(shader, GL_LINK_STATUS, &success);
        if (!success) {             // 如果链接失败
            // 获取详细的错误信息
            glGetProgramInfoLog(shader, 1024, NULL, infoLog);
            // 输出错误信息
            std::cerr << "着色器程序链接错误:\n" << infoLog << std::endl;
        }
    }
}

        接下来的核心内容,就是着色器类的初始化函数,它没有传入参数列表,使用的是类里的两个变量值来获得着色器源码位置,并编译初始化。它包含读取着色器文件源码、创建着色器、绑定着色器源码、编译源码等功能。

        其中将源码从string类型转为const char* 类型是必不可少的一步,因为在方法glShaderSource()函数里第三个参数的接受变量类型是const GLchar* const*:

void Shader::shaderInit() {
    // 读取文件,获取着色器源码
    std::string vertexSource = readFileToString(vertexPath);
    std::string fragmentSource = readFileToString(fragmentPath);

    // 转换为C风格字符串
    const char* vertexSourceCStr = vertexSource.c_str();
    const char* fragmentSourceCStr = fragmentSource.c_str();

    // 创建顶点着色器并编译
    unsigned int vertexShader = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vertexShader, 1, &vertexSourceCStr, NULL);
    glCompileShader(vertexShader);
    checkCompileErrors(vertexShader, "VERTEX");

    // 创建片段着色器并编译
    unsigned int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, &fragmentSourceCStr, NULL);
    glCompileShader(fragmentShader);
    checkCompileErrors(fragmentShader, "FRAGMENT");

    // 创建着色器程序并邦定着色器
    programId = glCreateProgram();
    glAttachShader(programId, vertexShader);
    glAttachShader(programId, fragmentShader);
    glLinkProgram(programId);
    checkCompileErrors(programId, "PROGRAM");

    // 删除已经绑定的着色器
    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);
}

        最后一个,也是最简单的一个函数,就是启用着色器函数:

void Shader::useProgram() {
    glUseProgram(programId);
}

        至此,我们的着色器类也就构建好了,我们现在可以去主程序里修改一些地方,来应用我们的Shader类。

       1、我们需要声明头文件,如果你是和我一样存放在shader文件夹下,你们可以使用语句:#include "shader/shader.h"来声明头文件。

        2、删除vertexShaderInit()、fragmentShaderInit()和shaderProgramInit()三个函数,并创建新函数用于初始化着色器:Shader* shaderInit(const std::string vertexPath, const std::string fragmentPath) 。

        3、在main函数中加入语句来调用方法,初始化着色器类。

        4、删除主程序中旧的调用函数初始化着色器程序的语句,以及更新相应变量。

// 着色器初始化函数,返回指向着色器类的指针
Shader* shaderInit(const std::string vertexPath, const std::string fragmentPath) {
    // 初始化着色器和着色器程序    
    Shader* ptrShader = new Shader(vertexPath, fragmentPath);
    ptrShader->shaderInit();
    return ptrShader;
}

// main函数里添加语句
const std::string vertexPath = "shader/vertexShader.txt";
const std::string fragmentPath = "shader/fragmentShader.txt";
Shader* shader = shaderInit(vertexPath, fragmentPath);

// 现在可以使用Shader类里的programId来为rend函数传入参数
render(shader -> programId, VAO);

着色器间的数据传递

        相信你也注意到了,在本文一开篇声明的顶点着色器中既有in的变量,也有out的变量,而在片段着色器中有in、out和uniform三种变量,这就涉及到了着色器之间的数据传递,其中in表示输入,out表示输出,uniform表示全局变量。

        如果我想要绘制一个彩色的三角形,那么我该在哪里传入数据并让渲染器渲染呢,答案是在顶点数组里,就是我们main函数开头就声明的vertices数组。因为在OpenGL中,颜色数据是存储在顶点中的,并不是像我们直觉中存储在面上的。这就使得颜色其实是顶点的一个属性,但顶点还有很多其他基础属性比如:位置。

        那我们既然在顶点数组中定义了颜色,那么它该放在哪里呢。一般的做法是:位置与颜色的交替存放,即位置1、颜色1;位置2、颜色2...在解释这些数据代表什么的时候,就需要用到我们之前说到的函数glVertexAttribPointer()和函数glEnableVertexAttribArray(),没有印象的可以回头看看前一节。

        这样我们就在程序运行的时候动态地将顶点数据传入顶点着色器中了,那么如何将数据传入到片段着色器呢,我们为什么需要把颜色输入到顶点着色器,不能直接输入到片段着色器中吗?答案是可以,但是不推荐这样做。还记得我们上一节开始的地方就提到的OpenGL渲染流程吗,它是从前到后依次传递数据,所以数据需要先经过顶点着色器,然后经过中间处理到片段着色器,最后测试与混合。当然我们后面也会介绍如何直接向片段着色器传递数据,但这就是直接从CPU向GPU传输数据,效率会慢。

        要经过顶点着色器,首先需要在顶点着色器中为变量配置一个逻辑索引——location,然后in传入数据,切记!!!在out传出数据之前一定需要在着色器中的main函数中处理一下!!!请不要尝试不经过main函数的直接输入输出,因为这样数据不会正确传出,最终会渲染出黑色的图案。并且!!!想要从在着色器之间传输数据,那么传出数据和传入数据的变量名、变量类型一定要相同!!!参考本篇开头着色器中传递的颜色变量。

        这里修改顶点参数,以及在VBOInit()方法里修改语句:

static float vertices[] = {
    0.5f, 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,
    -0.5f, -0.5f, 0.0f, 1.0f, 1.0f, 0.0f
};

// 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);

        解释完毕,运行主程序的代码,将会得到如下效果:

动态传入参数Uniform

        uniform是全局变量,只要定义后,在任何着色器或程序,在任意阶段都可以更改它。这里我们需要解决上面提到的问题:如何在程序运行时动态修改参数。这就要用到uniform。

        我们在主程序中添加一个方法,并在rend()方法中调用,来实现动态修改片段着色器中的全局变量timeColor,记得别忘记去片段着色器中将第一个颜色赋值语句注释,第二个颜色赋值语句解注释

void updateUniform(unsigned int shaderProgram) {
    float pi = 3.14;
    float time = glfwGetTime();
    float red = sin(time)/2.0f + 0.5f;
    float green = sin(time + pi / 6) / 2.0f + 0.5f;
    float blue = sin(time + pi / 3) / 2.0f + 0.5f;
    int vertexColorLocation = glGetUniformLocation(shaderProgram, "timeColor");
    glUniform4f(vertexColorLocation, red, green, blue, 1.0f);
}

// 在rend()方法中的glBindVertexArray(VAO);语句下调用
updateUniform(shaderProgram);

        运行得到效果如下,是一张动态改变颜色的矩形:

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐