这本书关注的是动画,而不是渲染。然而,渲染动画模型很重要。为了避免陷入任何特定的图形 API,在本章中,您将在 OpenGL 之上构建一个抽象层。这将是一个薄的抽象层,但它将让你在后面的章节中处理你的动画,而不必做任何特定于 OpenGL 的事情。
您将在本章中实现的抽象渲染器非常轻量级。它没有很多功能,只有那些你需要显示动画模型的功能。这应该使得将渲染器移植到其他 API 变得简单。
到本章结束时,您应该能够使用将要创建的抽象渲染代码向窗口渲染一些调试几何图形。在更高的层次上,您将学习以下内容:
-
如何创建着色器
-
如何在缓冲区中存储网格数据
-
如何将这些缓冲区绑定为着色器属性
-
如何向着色器发送统一数据
-
如何使用索引缓冲区呈现
-
如何加载纹理
-
基本 OpenGL 概念
-
创建和使用简单着色器
对 OpenGL 的一些熟悉将使这一章更容易理解。OpenGL、照明模型和着色器技巧不在本书的讨论范围之内。有关这些主题的更多信息,请查看https://learnopengl.com/。
抽象层最重要的部分是Shader
类。要绘制某些东西,必须绑定一个着色器,并为其附加一些属性和制服。着色器描述了被绘制的对象应该如何变换和着色,而属性定义了被绘制的对象。
在本节中,您将实现一个Shader
类,该类可以编译顶点和片段着色器。Shader
类也将返回统一和属性索引。
在实现Shader
类时,您将需要声明几个受保护的助手函数。这些函数将保持类的公共 API 干净;它们用于将文件读入字符串或调用 OpenGL 代码来编译着色器:
-
创建一个新文件来声明
Shader
类;称之为Shader.h
。Shader
类应该有一个 OpenGL 着色器对象的句柄,以及属性和统一索引的映射。这些字典有一个关键字字符串(属性或统一的名称)和一个值unsigned int
(统一或属性的索引):class Shader { private: unsigned int mHandle; std::map<std::string, unsigned int> mAttributes; std::map<std::string, unsigned int> mUniforms;
-
Shader
类的复制构造函数和赋值运算符应该被禁用。Shader
类不打算被值复制,因为它持有一个 GPU 资源的句柄:private: Shader(const Shader&); Shader& operator=(const Shader&);
-
接下来,需要在
Shader
类中声明辅助函数。ReadFile
功能将文件内容读入std::string
。CompileVertexShader
和CompileFragmentShader
函数编译着色器源代码并返回一个 OpenGL 句柄。LinkShader
功能将两个着色器链接到一个着色器程序中。PopulateAttribute
和PopulateUniform
功能将填写属性和统一字典:private: std::string ReadFile(const std::string& path); unsigned int CompileVertexShader( const std::string& vertex); unsigned int CompileFragmentShader( const std::string& fragment); bool LinkShaders(unsigned int vertex, unsigned int fragment); void PopulateAttributes(); void PopulateUniforms();
-
该类的默认构造函数将创建一个空的
Shader
对象。重载构造函数将调用Load
方法,该方法从文件中加载着色器并编译它们。析构函数将释放Shader
类持有的 OpenGL 着色器句柄:public: Shader(); Shader(const std::string& vertex, const std::string& fragment); ~Shader(); void Load(const std::string& vertex, const std::string& fragment);
-
在使用着色器之前,需要将其与
Bind
函数绑定。同样,不再使用后,可以与UnBind
功能解除绑定。GetAttribute
和GetUniform
函数在适当的字典中执行查找。GetHandle
函数返回着色器的 OpenGL 句柄:void Bind(); void UnBind(); unsigned int GetAttribute(const std::string& name); unsigned int GetUniform(const std::string& name); unsigned int GetHandle(); };
现在Shader
类声明已经完成,您将在下一节中实现它。
创建一个新文件Shader.cpp
,在中实现Shader
类。Shader
类实现对调用者隐藏了几乎所有实际的 OpenGL 代码。因为大多数 OpenGL 调用都是这样抽象的,在后面的章节中,你只需要直接调用抽象层,而不是 OpenGL 函数。
本书通篇使用统一数组。当着色器中遇到统一数组时(例如modelMatrices[120]
),由glGetActiveUniform
返回的统一名称是数组的第一个元素。在这个例子中,那就是modelMatrices[0]
。当遇到统一数组时,您希望遍历所有数组索引并获得每个元素的显式统一索引,但也希望存储没有任何下标的统一名称:
-
两个
Shader
构造函数都必须通过调用glCreateProgram
来创建一个新的着色器程序句柄。接受两个字符串的构造函数变量用字符串调用Load
函数。由于mHandle
始终是程序句柄,析构函数需要删除句柄:Shader::Shader() { mHandle = glCreateProgram(); } Shader::Shader(const std::string& vertex, const std::string& fragment) { mHandle = glCreateProgram(); Load(vertex, fragment); } Shader::~Shader() { glDeleteProgram(mHandle); }
-
ReadFile
助手功能使用std::ifstream
将文件转换成字符串,将文件的内容读入std::stringstream
。字符串流可用于将文件内容作为字符串返回:std::string Shader::ReadFile(const std::string& path) { std::ifstream file; file.open(path); std::stringstream contents; contents << file.rdbuf(); file.close(); return contents.str(); }
-
CompileVertexShader
函数是用于编译 OpenGL 顶点着色器的样板代码。首先,用glCreateShader
创建着色器对象,然后用glShaderSource
设置着色器的源。最后用glCompileShader
编译着色器。用glGetShaderiv
检查错误:unsigned int Shader::CompileVertexShader( const string& vertex) { unsigned int v = glCreateShader(GL_VERTEX_SHADER); const char* v_source = vertex.c_str(); glShaderSource(v, 1, &v_source, NULL); glCompileShader(v); int success = 0; glGetShaderiv(v, GL_COMPILE_STATUS, &success); if (!success) { char infoLog[512]; glGetShaderInfoLog(v, 512, NULL, infoLog); std::cout << "Vertex compilation failed.\n"; std::cout << "\t" << infoLog << "\n"; glDeleteShader(v); return 0; }; return v; }
-
CompileFragmentShader
功能与CompileVertexShader
功能几乎相同。唯一的真正的区别是glCreateShader
的参数,表示您正在创建一个片段着色器,而不是顶点着色器:unsigned int Shader::CompileFragmentShader( const std::string& fragment) { unsigned int f = glCreateShader(GL_FRAGMENT_SHADER); const char* f_source = fragment.c_str(); glShaderSource(f, 1, &f_source, NULL); glCompileShader(f); int success = 0; glGetShaderiv(f, GL_COMPILE_STATUS, &success); if (!success) { char infoLog[512]; glGetShaderInfoLog(f, 512, NULL, infoLog); std::cout << "Fragment compilation failed.\n"; std::cout << "\t" << infoLog << "\n"; glDeleteShader(f); return 0; }; return f; }
-
LinkShaders
辅助函数也是样板。将着色器附加到构造器创建的着色器程序句柄。通过调用glLinkProgram
链接着色器,并用glGetProgramiv
检查错误。一旦着色器被链接,您只需要程序;可以使用glDeleteShader
:bool Shader::LinkShaders(unsigned int vertex, unsigned int fragment) { glAttachShader(mHandle, vertex); glAttachShader(mHandle, fragment); glLinkProgram(mHandle); int success = 0; glGetProgramiv(mHandle, GL_LINK_STATUS, &success); if (!success) { char infoLog[512]; glGetProgramInfoLog(mHandle, 512, NULL, infoLog); std::cout << "ERROR: Shader linking failed.\n"; std::cout << "\t" << infoLog << "\n"; glDeleteShader(vertex); glDeleteShader(fragment); return false; } glDeleteShader(vertex); glDeleteShader(fragment); return true; }
删除单个着色器对象
-
PopulateAttributes
函数枚举存储在着色器程序中的所有属性,然后将它们存储为键值对,其中键是属性的名称,值是其位置。您可以使用glGetProgramiv
函数计算着色器程序中活动属性的数量,将GL_ACTIVE_ATTRIBUTES
作为参数名称。然后,通过索引遍历所有的属性,使用glGetActiveAttrib
获取每个属性的名称。最后,调用glGetAttribLocation
获取每个属性的位置:void Shader::PopulateAttributes() { int count = -1; int length; char name[128]; int size; GLenum type; glUseProgram(mHandle); glGetProgramiv(mHandle, GL_ACTIVE_ATTRIBUTES, &count); for (int i = 0; i < count; ++ i) { memset(name, 0, sizeof(char) * 128); glGetActiveAttrib(mHandle, (GLuint)i, 128, &length, &size, &type, name); int attrib = glGetAttribLocation(mHandle, name); if (attrib >= 0) { mAttributes[name] = attrib; } } glUseProgram(0); }
-
PopulateUniforms
辅助函数与PopulateAttributes
辅助函数非常相似。glGetProgramiv
需要以GL_ACTIVE_UNIFORMS
为参数名,需要调用glGetActiveUniform
和glGetUniformLocation
:void Shader::PopulateUniforms() { int count = -1; int length; char name[128]; int size; GLenum type; char testName[256]; glUseProgram(mHandle); glGetProgramiv(mHandle, GL_ACTIVE_UNIFORMS, &count); for (int i = 0; i < count; ++ i) { memset(name, 0, sizeof(char) * 128); glGetActiveUniform(mHandle, (GLuint)i, 128, &length, &size, &type, name); int uniform=glGetUniformLocation(mHandle, name); if (uniform >= 0) { // Is uniform valid?
-
当遇到有效的制服时,需要判断制服是否为数组。为此,在统一名称中搜索数组括号(
[
)。如果找到括号,制服就是一个数组:std::string uniformName = name; // if name contains [, uniform is array std::size_t found = uniformName.find('['); if (found != std::string::npos) {
-
如果遇到统一数组,从
[
开始擦除字符串中的所有内容。这将只给你留下统一的名字。然后,进入一个循环,试图通过将[ + index + ]
附加到统一名称来检索数组中的每个索引。一旦找到第一个无效索引,打破循环:uniformName.erase(uniformName.begin() + found, uniformName.end()); unsigned int uniformIndex = 0; while (true) { memset(testName,0,sizeof(char)*256); sprintf(testName, "%s[%d]", uniformName.c_str(), uniformIndex++); int uniformLocation = glGetUniformLocation( mHandle, testName); if (uniformLocation < 0) { break; } mUniforms[testName]=uniformLocation; } }
-
此时,
uniformName
包含制服的名称。如果该制服是一个数组,则名称的[0]
部分已被删除。将统一索引按名称存储在mUniforms
:
```cpp
mUniforms[uniformName] = uniform;
}
}
glUseProgram(0);
}
```
- 最后的辅助函数是
Load
函数,负责加载实际的着色器。这个函数接受两个字符串,它们要么是文件名,要么是内嵌着色器定义。一旦着色器被读取,调用Compile
、Link
和Populate
辅助函数来加载着色器:
```cpp
void Shader::Load(const std::string& vertex,
const std::string& fragment) {
std::ifstream f(vertex.c_str());
bool vertFile = f.good();
f.close();
f = std::ifstream(vertex.c_str());
bool fragFile = f.good();
f.close();
std::string v_source = vertex;
if (vertFile) {
v_source = ReadFile(vertex);
}
std::string f_source = fragment;
if (fragFile) {
f_source = ReadFile(fragment);
}
unsigned int vert = CompileVertexShader(v_source);
unsigned int f = CompileFragmentShader(f_source);
if (LinkShaders(vert, frag)) {
PopulateAttributes();
PopulateUniforms();
}
}
```
Bind
功能需要将当前着色器程序设置为活动状态,而UnBind
应确保没有Shader
对象处于活动状态。GetHandle
帮助器功能将 OpenGL 手柄返回到Shader
对象:
```cpp
void Shader::Bind() {
glUseProgram(mHandle);
}
void Shader::UnBind() {
glUseProgram(0);
}
unsigned int Shader::GetHandle() {
return mHandle;
}
```
- 最后,您需要一种检索属性和制服绑定槽的方法。
GetAttribute
功能将检查属性图中是否存在给定的属性名称。如果是,则返回代表它的整数。如果不是,则返回0
。0
是一个有效的属性索引,因此如果出现错误,也会记录一条错误消息:
```cpp
unsigned int Shader::GetAttribute(
const std::string& name) {
std::map<std::string, unsigned int>::iterator it =
mAttributes.find(name);
if (it == mAttributes.end()) {
cout << "Bad attrib index: " << name << "\n";
return 0;
}
return it->second;
}
```
GetUniform
功能的实现几乎与GetAttribute
功能相同,除了代替属性地图,它在统一地图上工作:
```cpp
unsigned int Shader::GetUniform(const std::string& name){
std::map<std::string, unsigned int>::iterator it =
mUniforms.find(name);
if (it == mUniforms.end()) {
cout << "Bad uniform index: " << name << "\n";
return 0;
}
return it->second;
}
```
Shader
类有检索制服和属性索引的方法。在下一节中,您将开始实现一个Attribute
类来保存传递给着色器的顶点数据。
属性是图形管道中的逐顶点数据。顶点由属性组成。例如,一个顶点有一个位置和一个法线,这两个都是属性。最常见的属性如下:
- 位置:通常在局部空间
- 法线:顶点指向的方向
- UV 或纹理坐标:纹理上的归一化( x , y )坐标
- 颜色:表示顶点颜色的
vector3
属性可以有不同的数据类型。在本书中,您将实现对整数、浮点和向量属性的支持。对于向量属性,将支持二维、三维和四维向量。
创建新文件,Attribute.h
。Attribute
类将在这个新文件中声明。Attribute
班将以为模板。这将确保如果一个属性意味着是vec3
,您不会意外地将vec2
载入其中:
-
属性类将包含两个成员变量,一个用于 OpenGL 属性句柄,一个用于计算
Attribute
类包含多少数据。由于属性数据存在于 GPU 上,并且您不希望同一数据有多个句柄,因此复制构造函数和assignment operator
应该被禁用:template<typename T> class Attribute { protected: unsigned int mHandle; unsigned int mCount; private: Attribute(const Attribute& other); Attribute& operator=(const Attribute& other);
-
SetAttribPointer
功能是特殊的,因为它需要为支持的每种属性实现一次。这将在.cpp
文件中明确完成,稍后:void SetAttribPointer(unsigned int slot);
-
将属性类的构造函数和析构函数声明为公共函数:
public: Attribute(); ~Attribute();
-
Attribute
类需要一个Set
函数,这个函数会将一组数据上传到 GPU。数组中的每个元素代表一个顶点的属性。我们需要一种从着色器定义的绑定槽中绑定和解除绑定属性的方法,以及属性计数和句柄的访问器:void Set(T* inputArray, unsigned int arrayLength); void Set(std::vector<T>& input); void BindTo(unsigned int slot); void UnBindFrom(unsigned int slot); unsigned int Count(); unsigned int GetHandle(); };
现在您已经声明了Attribute
类,您将在下一节中实现它。
创建新文件,Attribtue.cpp
。您将在此文件中实现Attribute
类,如下所示:
-
Attribute
类是模板化的,但是它的函数没有一个被标记为内联的。每个属性类型的模板专门化将存在于Attribute.cpp
文件中。为整数、浮点、vec2
、vec3
、vec4
和ivec4
类型添加专门化:template Attribute<int>; template Attribute<float>; template Attribute<vec2>; template Attribute<vec3>; template Attribute<vec4>; template Attribute<ivec4>;
-
构造函数应该生成一个 OpenGL 缓冲区,并将其存储在
Attribute
类的句柄中。析构函数负责释放Attribute
类持有的句柄:template<typename T> Attribute<T>::Attribute() { glGenBuffers(1, &mHandle); mCount = 0; } template<typename T> Attribute<T>::~Attribute() { glDeleteBuffers(1, &mHandle); }
-
Attribute
类有两个简单的 getters,一个用来检索计数,一个用来检索 OpenGL 句柄。计数表示总共有多少属性:template<typename T> unsigned int Attribute<T>::Count() { return mCount; } template<typename T> unsigned int Attribute<T>::GetHandle() { return mHandle; }
-
Set
函数取一个数组和一个长度。然后,它绑定Attribute
类保留的缓冲区,并使用glBufferData
用数据填充缓冲区。Set
有一个方便的函数,用向量引用代替数组。它调用实际的Set
函数:template<typename T> void Attribute<T>::Set(T* inputArray, unsigned int arrayLength) { mCount = arrayLength; unsigned int size = sizeof(T); glBindBuffer(GL_ARRAY_BUFFER, mHandle); glBufferData(GL_ARRAY_BUFFER, size * mCount, inputArray, GL_STREAM_DRAW); glBindBuffer(GL_ARRAY_BUFFER, 0); } template<typename T> void Attribute<T>::Set(std::vector<T>& input) { Set(&input[0], (unsigned int)input.size()); }
-
SetAttribPointer
功能包装glVertesAttribPointer
或glVertesAttribIPointer
。根据Attribute
类的类型,参数和要调用的函数是不同的。要消除任何歧义,请为所有支持的模板类型提供显式实现。首先执行int
、ivec4
和float
类型:template<> void Attribute<int>::SetAttribPointer(unsigned int s) { glVertexAttribIPointer(s, 1, GL_INT, 0, (void*)0); } template<> void Attribute<ivec4>::SetAttribPointer(unsigned int s){ glVertexAttribIPointer(s, 4, GL_INT, 0, (void*)0); } template<> void Attribute<float>::SetAttribPointer(unsigned int s){ glVertexAttribPointer(s,1,GL_FLOAT,GL_FALSE,0,0); }
-
接下来执行
vec2
、vec3
和vec4
类型。这些都与float
型非常相似。唯一不同的是glVertexAttribPointer
的第二个论点:template<> void Attribute<vec2>::SetAttribPointer(unsigned int s) { glVertexAttribPointer(s,2,GL_FLOAT,GL_FALSE,0,0); } template<> void Attribute<vec3>::SetAttribPointer(unsigned int s){ glVertexAttribPointer(s,3,GL_FLOAT,GL_FALSE,0,0); } template<> void Attribute<vec4>::SetAttribPointer(unsigned int s){ glVertexAttribPointer(s,4,GL_FLOAT,GL_FALSE,0,0); }
-
Attribute
类的最后两个函数需要将属性绑定和解除绑定到Shader
类中指定的插槽。由于glVertexAttribPointer
函数基于Attribute
类的模板类型而不同,Bind
将调用SetAttribPointer
辅助函数:template<typename T> void Attribute<T>::BindTo(unsigned int slot) { glBindBuffer(GL_ARRAY_BUFFER, mHandle); glEnableVertexAttribArray(slot); SetAttribPointer(slot); glBindBuffer(GL_ARRAY_BUFFER, 0); } template<typename T> void Attribute<T>::UnBindFrom(unsigned int slot) { glBindBuffer(GL_ARRAY_BUFFER, mHandle); glDisableVertexAttribArray(slot); glBindBuffer(GL_ARRAY_BUFFER, 0); }
Attribute
每个顶点的数据变化。你还需要设置另一种类型的数据:制服。与属性不同,制服在着色器程序的整个执行过程中保持不变。您将在下一部分实施制服。
与属性不同,制服是不变的数据;它们被设置一次。对于处理的所有顶点,统一的值保持不变。制服可以创建为数组,这是您将在后面的章节中用来实现网格蒙皮的功能。
像Attribute
类一样,Uniform
类也将被模板化。然而,与属性不同的是,永远不会有Uniform
类的实例。它只需要公共静态函数。对于每种统一类型,有三个函数:一个用于设置单个统一值,一个用于设置统一值数组,还有一个方便函数用于设置值数组,但使用向量进行输入。
创建新文件,Uniform.h
。您将在这个新文件中实现Uniform
类。Uniform
类永远不会被实例化,因为这个类不会有任何实例。禁用构造函数并复制构造函数、赋值运算符和析构函数。该类将拥有一个静态Set
函数的三个重载。需要为每个模板类型指定Set
功能:
template <typename T>
class Uniform {
private:
Uniform();
Uniform(const Uniform&);
Uniform& operator=(const Uniform&);
~Uniform();
public:
static void Set(unsigned int slot, const T& value);
static void Set(unsigned int slot,T* arr,unsigned int len);
static void Set(unsigned int slot, std::vector<T>& arr);
};
你刚刚完成了Uniform
班的申报。在下一节中,您将开始实现Uniform
类。
创建新文件,Uniform.cpp
。您将在这个新文件中实现Uniform
类。像Attribute
类一样,Uniform
类也是模板化的。
在 OpenGL 中,制服是用glUniform***
系列函数设置的。整数、浮点数、向量、矩阵等等都有不同的函数。您希望为这些类型中的每一种提供Set
方法的实现,但是避免编写几乎相同的代码。
为了避免编写几乎相同的代码,您将声明一个# define
宏。这个宏将采用三个参数——要调用的 OpenGL 函数、统一类的模板类型和 OpenGL 函数的数据类型:
-
添加以下代码来定义支持的统一类型的模板规范:
template Uniform<int>; template Uniform<ivec4>; template Uniform<ivec2>; template Uniform<float>; template Uniform<vec2>; template Uniform<vec3>; template Uniform<vec4>; template Uniform<quat>; template Uniform<mat4>;
-
您只需要为每种类型实现其中一个
Set
方法——一个需要数组和长度的方法。其他Set
方法重载是为了方便。实现两个便利重载——其中一个用于设置单个统一,另一个用于设置向量。两个重载都应该只调用Set
函数:template <typename T> void Uniform<T>::Set(unsigned int slot,const T& value){ Set(slot, (T*)&value, 1); } template <typename T> void Uniform<T>::Set(unsigned int s,std::vector<T>& v){ Set(s, &v[0], (unsigned int)v.size()); }
-
创建
UNIFORM_IMPL
宏。第一个参数将是调用哪个 OpenGL 函数,第二个参数是正在使用的类型的结构,最后一个参数是相同结构的数据类型。UNIFORM_IMPL
宏将这些信息组合成一个函数声明:#define UNIFORM_IMPL(gl_func, tType, dType) \ template<> void Uniform<tType>::Set(unsigned int slot,\ tType* data, unsigned int length) {\ gl_func(slot, (GLsizei)length, (dType*)&data[0]); \ }
-
为每种统一的数据类型调用
UNIFORM_IMPL
宏,生成合适的Set
函数。这种方法唯一不起作用的数据类型是mat4
:UNIFORM_IMPL(glUniform1iv, int, int) UNIFORM_IMPL(glUniform4iv, ivec4, int) UNIFORM_IMPL(glUniform2iv, ivec2, int) UNIFORM_IMPL(glUniform1fv, float, float) UNIFORM_IMPL(glUniform2fv, vec2, float) UNIFORM_IMPL(glUniform3fv, vec3, float) UNIFORM_IMPL(glUniform4fv, vec4, float) UNIFORM_IMPL(glUniform4fv, quat, float)
-
矩阵的
Set
功能需要手动指定;否则,UNIFORM_IMPL
宏将不起作用。这是因为glUniformMatrix4fv
函数接受了一个额外的布尔参数,询问矩阵是否应该转置。将转置布尔设置为false
:template<> void Uniform<mat4>::Set(unsigned int slot, mat4* inputArray, unsigned int arrayLength) { glUniformMatrix4fv(slot, (GLsizei)arrayLength, false, (float*)&inputArray[0]); }
在本节中,您在制服概念的基础上构建了一个抽象层。在下一节中,您将实现类似于属性的索引缓冲区。
索引缓冲区是一种属性。与属性不同,索引缓冲区绑定到GL_ELEMENT_ARRAY_BUFFER
并且可以是用于绘制图元。因此,您将在自己的类中实现索引缓冲区,而不是重用Attribute
类。
创建新文件,IndexBuffer.h
。您将把IndexBuffer
类的声明添加到这个新文件中。像一个Attribute
对象一样,IndexBuffer
将包含一个 OpenGL 句柄和一个计数,两者都有 getter 函数。
需要禁用复制构造函数和赋值运算符,以避免多个IndexBuffer
对象引用同一个 OpenGL 缓冲区。Set
函数接受一个无符号整数数组和数组的长度,但是也有一个方便的重载接受一个向量:
class IndexBuffer {
public:
unsigned int mHandle;
unsigned int mCount;
private:
IndexBuffer(const IndexBuffer& other);
IndexBuffer& operator=(const IndexBuffer& other);
public:
IndexBuffer();
~IndexBuffer();
void Set(unsigned int* rr, unsigned int len);
void Set(std::vector<unsigned int>& input);
unsigned int Count();
unsigned int GetHandle();
};
在本节中,您声明了一个新的IndexBuffer
类。在下一节中,您将开始实现实际的索引缓冲区。
索引缓冲区允许您使用索引几何图形渲染模型。想一个人体模型;网格中几乎所有的三角形都将被连接。这意味着许多三角形可能共享一个顶点。不是存储每个顶点,而是只存储唯一的顶点。索引到唯一顶点列表的缓冲区,即索引缓冲区,用于从唯一顶点创建三角形,如下所示:
-
创建新文件,
IndexBuffer.cpp
。您将在这个文件中实现IndexBuffer
类。构造器需要生成一个新的 OpenGL 缓冲区,析构器需要删除该缓冲区:IndexBuffer::IndexBuffer() { glGenBuffers(1, &mHandle); mCount = 0; } IndexBuffer::~IndexBuffer() { glDeleteBuffers(1, &mHandle); }
-
用于计数的 getter 函数和
IndexBuffer
对象内部的 OpenGL 句柄是微不足道的:unsigned int IndexBuffer::Count() { return mCount; } unsigned int IndexBuffer::GetHandle() { return mHandle; }
-
IndexBuffer
类的Set
功能需要绑定GL_ELEMENT_ARRAY_BUFFER
。除此之外,逻辑与属性相同:void IndexBuffer::Set(unsigned int* inputArray, unsigned int arrayLengt) { mCount = arrayLengt; unsigned int size = sizeof(unsigned int); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, mHandle); glBufferData(GL_ELEMENT_ARRAY_BUFFER, size * mCount, inputArray, GL_STATIC_DRAW); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); } void IndexBuffer::Set(std::vector<unsigned int>& input) { Set(&input[0], (unsigned int)input.size()); }
在本节中,您围绕索引缓冲区构建了一个抽象。在下一节中,您将学习如何使用索引缓冲区和属性来渲染几何图形。
您有处理顶点数据、制服和索引缓冲区的类,但没有任何代码来绘制它们。绘图将由四个全局功能处理。你会有两个Draw
功能和两个DrawInstanced
功能。您可以使用或不使用索引缓冲区来绘制几何图形。
创建新文件,Draw.h
。您将在该文件中实现Draw
功能,如下所示:
-
声明一个
enum
类,该类定义了应该用于绘制的图元。大多数情况下,您只需要线条、点或三角形,但一些附加类型可能会有用:enum class DrawMode { Points, LineStrip, LineLoop, Lines, Triangles, TriangleStrip, TriangleFan };
-
接下来,声明
Draw
功能。Draw
函数有两个重载——一个采用索引缓冲区和绘制模式,另一个采用顶点计数和绘制模式:void Draw(IndexBuffer& inIndexBuffer, DrawMode mode); void Draw(unsigned int vertexCount, DrawMode mode);
-
像
Draw
一样,声明两个DrawInstanced
函数。这些函数有一个相似的签名,但是有一个额外的参数——instanceCount
。这个instanceCount
变量控制将渲染多少几何实例:void DrawInstanced(IndexBuffer& inIndexBuffer, DrawMode mode, unsigned int instanceCount); void DrawInstanced(unsigned int vertexCount, DrawMode mode, unsigned int numInstances);
创建新文件,Draw.cpp
。您将在此文件中实现与图形相关的功能,如下所示:
-
您需要能够将
DrawMode
枚举转换为GLenum
。我们将使用静态助手功能来实现这一点。这个函数唯一需要做的就是弄清楚输入绘制模式是什么,并返回适当的GLenum
值:static GLenum DrawModeToGLEnum(DrawMode input) { switch (input) { case DrawMode::Points: return GL_POINTS; case DrawMode::LineStrip: return GL_LINE_STRIP; case DrawMode::LineLoop: return GL_LINE_LOOP; case DrawMode::Lines: return GL_LINES; case DrawMode::Triangles: return GL_TRIANGLES; case DrawMode::TriangleStrip: return GL_TRIANGLE_STRIP; case DrawMode::TriangleFan: return GL_TRIANGLE_FAN; } cout << "DrawModeToGLEnum unreachable code hit\n"; return 0; }
-
进行顶点计数的
Draw
和DrawInstanced
函数实现起来很简单。Draw
需要调用glDrawArrays
,DrawInstanced
需要调用glDrawArraysInstanced
:void Draw(unsigned int vertexCount, DrawMode mode) { glDrawArrays(DrawModeToGLEnum(mode), 0, vertexCount); } void DrawInstanced(unsigned int vertexCount, DrawMode mode, unsigned int numInstances) { glDrawArraysInstanced(DrawModeToGLEnum(mode), 0, vertexCount, numInstances); }
-
获取索引缓冲区的
Draw
和Drawinstanced
函数需要将索引缓冲区绑定到GL_ELEMENT_ARRAY_BUFFER
,然后调用glDrawElements
和【T4:void Draw(IndexBuffer& inIndexBuffer, DrawMode mode) { unsigned int handle = inIndexBuffer.GetHandle(); unsigned int numIndices = inIndexBuffer.Count(); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, handle); glDrawElements(DrawModeToGLEnum(mode), numIndices, GL_UNSIGNED_INT, 0); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); } void DrawInstanced(IndexBuffer& inIndexBuffer, DrawMode mode, unsigned int instanceCount) { unsigned int handle = inIndexBuffer.GetHandle(); unsigned int numIndices = inIndexBuffer.Count(); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, handle); glDrawElementsInstanced(DrawModeToGLEnum(mode), numIndices, GL_UNSIGNED_INT, 0, instanceCount); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); }
到目前为止,您已经编写了加载着色器、创建和绑定 GPU 缓冲区以及将制服传递给着色器的代码。既然绘图代码也已经实现,就可以开始显示几何图形了。
在下一节中,您将学习如何使用纹理使渲染的几何图形看起来更有趣。
你将在这本书里写的所有着色器都假设被渲染的物体的漫射颜色来自于一个纹理。纹理将从.png
文件加载。所有图像加载将通过stb_image
完成。
Stb
是单文件公共领域库的集合。我们只使用图像加载器;你可以在https://github.com/nothings/stb的 GitHub 上找到整个stb
系列。
你将使用stb_image
来加载纹理。您可以从https://github.com/nothings/stb/blob/master/stb_image.h获得头文件的副本。将stb_image.h
头文件添加到项目中。
创建新文件,stb_image.cpp
。这个文件只需要声明stb_image
实现宏并包含头文件。应该是这样的:
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
创建新文件,Texture.h
。您将在该文件中声明Texture
类。Texture
级只需要一个几个重要的功能。它需要能够从文件中加载纹理,将纹理索引绑定到统一索引,并停用纹理索引。
除了核心函数之外,该类应该有一个默认构造函数、一个获取文件路径的便利构造函数、一个析构函数和一个包含在Texture
类内部的 OpenGL 句柄的获取器。应该禁用复制构造函数和赋值操作符,以避免两个Texture
类引用同一个 OpenGL 纹理句柄:
class Texture {
protected:
unsigned int mWidth;
unsigned int mHeight;
unsigned int mChannels;
unsigned int mHandle;
private:
Texture(const Texture& other);
Texture& operator=(const Texture& other);
public:
Texture();
Texture(const char* path);
~Texture();
void Load(const char* path);
void Set(unsigned int uniform, unsigned int texIndex);
void UnSet(unsigned int textureIndex);
unsigned int GetHandle();
};
创建新文件,Texture.cpp
。Texture
类的定义将包含在这个文件中。Texture
类的默认构造器需要将所有成员变量设置为0
,然后生成一个 OpenGL 句柄。
Load
函数可能是Texture
类中最重要的函数;它负责加载图像文件。图像文件的实际解析将由stbi_load
处理:
-
便利构造器生成一个新的句柄,然后调用
Load
函数,该函数将初始化其余的类成员变量,因为Texture
类的每个实例都持有一个有效的纹理句柄:Texture::Texture() { mWidth = 0; mHeight = 0; mChannels = 0; glGenTextures(1, &mHandle); } Texture::Texture(const char* path) { glGenTextures(1, &mHandle); Load(path); } Texture::~Texture() { glDeleteTextures(1, &mHandle); }
-
stbi_load
获取图像文件的路径,并引用图像中通道的宽度、高度和数量。最后一个参数指定每个像素的组件数量。通过将其设置为4
,所有纹理都加载了 RGBA 通道。接下来,使用glTexImage2D
将纹理上传到图形处理器,使用glGenerateMipmap
为图像生成合适的纹理贴图。将环绕模式设置为重复:void Texture::Load(const char* path) { glBindTexture(GL_TEXTURE_2D, mHandle); int width, height, channels; unsigned char* data = stbi_load(path, &width, &height, &channels, 4); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data); glGenerateMipmap(GL_TEXTURE_2D); stbi_image_free(data); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER, GL_LINEAR); glBindTexture(GL_TEXTURE_2D, 0); mWidth = width; mHeight = height; mChannels = channels; }
-
Set
函数需要激活一个纹理单元,将Texture
类包含的句柄绑定到该纹理单元,然后设置指定的统一索引来包含当前绑定的纹理单元。Unset
功能解除当前纹理与指定纹理单位的绑定:void Texture::Set(unsigned int uniformIndex, unsigned int textureIndex) { glActiveTexture(GL_TEXTURE0 + textureIndex); glBindTexture(GL_TEXTURE_2D, mHandle); glUniform1i(uniformIndex, textureIndex); } void Texture::UnSet(unsigned int textureIndex) { glActiveTexture(GL_TEXTURE0 + textureIndex); glBindTexture(GL_TEXTURE_2D, 0); glActiveTexture(GL_TEXTURE0); }
-
GetHandle
getter 函数很简单:unsigned int Texture::GetHandle() { return mHandle; }
Texture
类将始终使用相同的 mipmap 级别和包装参数加载纹理。对于本书中的样本来说,这应该足够了。您可能想尝试为这些属性添加吸气剂和设置剂。
在下一节中,您将实现顶点和片段着色器程序,这是绘制某些东西所需的最后一步。
渲染抽象完成。在绘制任何东西之前,你需要编写着色器来指导如何绘制东西。在本节中,您将编写一个顶点和一个片段着色器。碎片着色器将在本书的其余部分中使用,本书后面部分中使用的顶点着色器将是这里介绍的着色器的变体。
顶点着色器负责将模型的每个顶点通过模型、视图和投影管道,并将任何所需的光照数据传递给片段着色器。创建新文件,static.vert
。您将在这个文件中实现顶点着色器。
顶点着色器采用三种统一的格式——模型、视图和投影矩阵。变换一个顶点需要这些制服。每个单独的顶点由三个属性组成——位置、法线和一些纹理坐标。
顶点着色器向片段着色器输出三个变量,即世界空间中的法线和片段位置以及纹理坐标:
#version 330 core
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
in vec3 position;
in vec3 normal;
in vec2 texCoord;
out vec3 norm;
out vec3 fragPos;
out vec2 uv;
void main() {
gl_Position = projection * view * model *
vec4(position, 1.0);
fragPos = vec3(model * vec4(position, 1.0));
norm = vec3(model * vec4(normal, 0.0f));
uv = texCoord;
}
这是一个最小顶点着色器;它仅通过模型视图和投影管道放置顶点。该着色器可用于显示静态几何图形或 CPU 蒙皮网格。在下一节中,您将实现一个片段着色器。
创建新文件,lit.frag
。该文件中的片段着色器将在本书的其余部分中使用。一些章节将引入新的顶点着色器,但是片段着色器将一直保持这个。
片段着色器从纹理中获取对象的漫射颜色,然后应用单向光。灯光模型只是 N 点 L 。由于光线没有环境术语,模型的某些部分可能显示为全黑:
#version 330 core
in vec3 norm;
in vec3 fragPos;
in vec2 uv;
uniform vec3 light;
uniform sampler2D tex0;
out vec4 FragColor;
void main() {
vec4 diffuseColor = texture(tex0, uv);
vec3 n = normalize(norm);
vec3 l = normalize(light);
float diffuseIntensity = clamp(dot(n, l), 0, 1);
FragColor = diffuseColor * diffuseIntensity;
}
重要信息:
想了解更多关于 OpenGL 中灯光模型的吗?前往https://learnopengl.com/Lighting/Basic-Lighting。
这是一个简单的片段着色器;漫射颜色是通过采样纹理获得的,强度是简单的定向光。
在本章中,您学习了如何在 OpenGL API 之上编写抽象层。大部分情况下,在本书的剩余部分中,您将使用这些类来绘制东西,但是一些零星的 OpenGL 调用可能会在我们的代码中到处出现。
以这种方式抽象 OpenGL 将让未来的章节专注于动画,而不必担心底层的 API。将这个应用编程接口移植到其他后端应该也很简单。
本章有两个示例——Chapter06/Sample00
,这是到目前为止使用的代码,以及Chapter06/Sample01
,这显示了一个简单的纹理和照明平面旋转到位。Sample01
是一个很好的例子,说明如何使用你到目前为止编写的代码。
Sample01
还包括一个效用类DebugDraw
,本书不会涉及。该类位于DebugDraw.h
和DebugDraw.cpp
。DebugDraw
类可以用一个简单的应用编程接口快速绘制调试线。DebugDraw
班效率不是很高;它只是用来调试的。
在下一章中,您将开始探索 glTF 文件格式。glTF 是一种标准格式,可以存储网格和动画数据。这是本书其余部分将使用的格式。