[OpenGL入门教程] 4. 构造着色器类

着色器(Shader)是运行在GPU上的小程序。这些小程序为图形渲染管线的某个特定部分而运行。

从基本意义上来说,着色器只是一种把输入转化为输出的程序。

着色器结构

一个典型的着色器有下面的结构:

#version version_number
in type in_variable;
in type in_variable_2;

out type out_variable;

uniform type uniform_1;

int main()
{
  // 处理输入并进行一些图形操作
  ...
  // 输出处理过的结果到输出变量
  out_variable_name = stuff_we_processed;
}

着色器的开头总是要声明版本,接着是输入和输出变量、uniformmain函数。每个着色器的入口点都是main函数,在这个函数中我们处理所有的输入变量,并将结果输出到输出变量中。

uniform是一种从CPU中的应用向GPU中的着色器发送数据的方式,但uniform和顶点属性有些不同。首先,uniform是全局的,意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。第二,无论你把uniform值设置成什么,uniform会一直保存它们的数据,直到它们被重置或更新。

可以在一个着色器中添加uniform关键字至类型和变量名前来声明一个GLSL的uniform。接着就可以在着色器中使用新声明的uniform了。

#version 330 core
out vec4 FragColor;

uniform vec4 ourColor; 

void main()
{
    FragColor = ourColor;
}

这里在片段着色器中声明了一个uniformvec4类型的ourColor,并把片段着色器的输出颜色设置为uniform值的内容。

因为uniform是全局变量,可以在任何着色器中定义它们,而无需通过顶点着色器作为中介。顶点着色器中不需要这个uniform,所以不用在那里定义它。

为了向这个uniform中传递数据,需要在CPU程序中获取到该属性的索引,然后更新其值。

int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, 0.5f, 0.0f, 1.0f);

通过glGetUniformLocation可以查询uniform的位置值。为查询函数提供着色器程序和uniform的名字。如果glGetUniformLocation返回-1就代表没有找到这个位置值。

通过glUniform4f函数可以设置vec4的float类型的uniform值。类似的也有glUniform2i对应vec2的int数组的uniform等。注意,查询uniform地址不要求你之前使用过着色器程序,但是更新一个uniform之前你必须先使用程序(调用glUseProgram),因为它是在当前激活的着色器程序中设置uniform的。

向量

在顶点着色器中,每个输入变量也叫顶点属性(Vertex Attribute)。我们能声明的顶点属性是有上限的,它一般由硬件来决定。

OpenGL确保至少有16个包含4分量的顶点属性可用,但是有些硬件或许允许更多的顶点属性,可以通过glGetIntegerv函数查询GL_MAX_VERTEX_ATTRIBS来获取具体的上限。

glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);

GLSL中包含C等其它语言大部分的默认基础数据类型:intfloatdoubleuintbool,,以及两种容器类型向量(Vector)和矩阵(Matrix)。

GLSL中的向量是一个可以包含有2、3或者4个分量的容器,分量的类型可以是前面默认基础类型的任意一个。默认的vecn表示包含nfloat分量的默认向量,对应的有bvecn表示包含nbool分量的向量,ivecn表示包含nint分量的向量等。可以分别使用.x.y.z.w来获取它们的第1、2、3、4个分量。

输入输出

GLSL定义了inout关键字,每个着色器使用这两个关键字设定输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。所以,如果打算从一个着色器向另一个着色器发送数据,必须在发送方着色器中声明一个输出,在接收方着色器中声明一个类似的输入当类型和名字都一样的时候,OpenGL就会把两个变量链接到一起,它们之间就能发送数据了(这是在链接程序对象时完成的)。

顶点着色器的输入特殊在,它从顶点数据中直接接收输入。为了定义顶点数据该如何管理,使用location这一元数据指定输入变量,这样才可以在CPU上配置顶点属性。例如前面使用过的layout (location = 0),顶点着色器需要为它的输入提供一个额外的layout标识。

而片段着色器需要一个vec4颜色输出变量,因为片段着色器需要生成一个最终输出的颜色。如果在片段着色器没有定义输出颜色,OpenGL会把你的物体渲染为黑色(或白色)。

编写着色器类

有了上面的了解,目前已经可以成功地通过着色器来构建我们的图形了。但是每次更改都要改动一大堆代码,它们分布在整个程序中,每次相当麻烦。并且,目前的程序是以字符串形式写在程序里的,这显然是不符合需求的,应该改为从文件中读取。

基于这些需求,可以将着色器部分抽象成一个类,对外提供这些接口,从而便于管理和改动。

下面是着色器类的头文件

#pragma once
#include <string>
#include <iostream>
#include <GL/glew.h>
#include <fstream>
#include <sstream>
#include <unordered_map>

struct ShaderProgramSource
{
	std::string VertexSource;
	std::string FragmentSource;
};

class Shader
{
private:
	unsigned int m_RendererID;
	std::string m_file;
	std::unordered_map<std::string, int> m_location_memory;

	int GetUniformLocation(const std::string& name);
	ShaderProgramSource ParseShader();
	unsigned int compile_shader(const std::string& source, unsigned int type);
	unsigned int create_shader(const std::string& vertexShader, const std::string& fragmentShader);
public:
	Shader(const std::string& filename);
	~Shader();

	void Bind() const;
	void Unbind() const;

	// set uniforms
	void SetUniform4f(const std::string& name, float v0, float v1, float v2, float v3);

	void SetUniform1i(const std::string& name, int value);

	void SetUniform1f(const std::string& name, float value);

};

首先着色器类需要储存着色器程序的ID,它的构造器需要顶点和片段着色器源代码的文件路径,这样我们就可以把源码的文本文件储存在硬盘上了。其次,为了方便调试,这里将文件名也作为变量储存了。

然后是如何读取和设置程序,这里将顶点着色器和片段着色器内容放在同一个文件中,因此需要一个额外的解析工作。其余部分与之前类似,将其放进函数中即可。为了方便起见,提供了设置Uniform的方法和绑定解绑的方法。

下面是具体的实现

#include "Shader.h"
#include "renderer.h"

int Shader::GetUniformLocation(const std::string& name)
{
    if (m_location_memory.find(name) != m_location_memory.end()) {
        return m_location_memory[name];
    }
    GLCall(int location = glGetUniformLocation(m_RendererID, name.c_str()));
    m_location_memory[name] = location;
	return location;
}

Shader::Shader(const std::string& filename):m_file(filename),m_RendererID (0)
{
    GLCall(ShaderProgramSource source = ParseShader());
    m_RendererID = create_shader(source.VertexSource, source.FragmentSource);
}

Shader::~Shader()
{
    GLCall(glDeleteProgram(m_RendererID));
}


void Shader::Bind() const
{
    GLCall(glUseProgram(m_RendererID));
}

void Shader::Unbind() const
{
    GLCall(glUseProgram(0));
}

ShaderProgramSource Shader::ParseShader()
{
    std::ifstream file(m_file);

    enum class ShaderType
    {
        NONE = -1, VERTEX = 0, FRAGMENT = 1
    };
    std::stringstream ss[2];
    std::string line;
    ShaderType type = ShaderType::NONE;
    while (getline(file, line)) {
        if (line.find("#shader") != std::string::npos)
        {
            if (line.find("vertex") != std::string::npos)
            {
                type = ShaderType::VERTEX;
            }
            else if (line.find("fragment") != std::string::npos)
            {
                type = ShaderType::FRAGMENT;
            }
        }
        else {
            ss[(int)type] << line << "\n";
        }
    }
    return { ss[0].str(),ss[1].str() };
}

void Shader::SetUniform4f(const std::string& name, float v0, float v1, float v2, float v3)
{
    int location = GetUniformLocation(name);
    ASSERT(location != -1);
    GLCall(glUniform4f(location, v0, v1, v2, v3));
}

void Shader::SetUniform1i(const std::string& name, int value)
{
    int location = GetUniformLocation(name);
    ASSERT(location != -1);
    GLCall(glUniform1i(location, value));
}

void Shader::SetUniform1f(const std::string& name, float value)
{
    int location = GetUniformLocation(name);
    ASSERT(location != -1);
    GLCall(glUniform1f(location, value));
}

unsigned int Shader::compile_shader(const std::string& source, unsigned int type)
{
    unsigned int id = glCreateShader(type);
    const char* src = source.c_str();

    glShaderSource(id, 1, &src, nullptr);
    glCompileShader(id);

    int result;
    glGetShaderiv(id, GL_COMPILE_STATUS, &result);
    if (result == GL_FALSE)
    {
        int length;
        glGetShaderiv(id, GL_INFO_LOG_LENGTH, &length);
        char* message = (char*)alloca(sizeof(char) * (length));
        glGetShaderInfoLog(id, length, &length, message);
        std::cout << (type == GL_VERTEX_SHADER ? "Vertex" : "") << " shader编译失败" << std::endl;
        std::cout << message << std::endl;
        glDeleteShader(id);
        return 0;
    }

    return id;
}

unsigned int Shader::create_shader(const std::string& vertexShader, const std::string& fragmentShader)
{
    unsigned int program = glCreateProgram();
    unsigned int vs = compile_shader(vertexShader, GL_VERTEX_SHADER);
    unsigned int fs = compile_shader(fragmentShader, GL_FRAGMENT_SHADER);

    glAttachShader(program, vs);
    glAttachShader(program, fs);

    glLinkProgram(program);

    glValidateProgram(program);

    glDeleteShader(vs);
    glDeleteShader(fs);

    return program;
}

这个renderer.h是自定义的一个头文件,中定义了一个错误检测的宏定义GLCall,其功能就是如果某个函数运行出错就会打印错误码和行号。如果不使用的话,直接去掉即可。

参考资料

[1] https://learnopengl-cn.readthedocs.io/zh/latest/01%20Getting%20started/03%20Hello%20Window/

[2] http://bit.ly/2lt7ccM