前言
最近工作中遇到了一些可视化渲染相关的问题,比如多线程下opegl渲染上下文如何共用、GPU与CPU内存数据交换性能如何提升、仿射变换卡顿以及threejs中矩阵变换、透明度叠加、深度测试等,虽然针对单个问题在网上能查到很多解决方案,也解决了大部分问题,但内心始终感觉缺点啥,缺乏可视化相关的系统性的专业知识支撑,遇到这些问题时,内心着实有点慌,因此,下定决心要把opengl相关的基础知识系统性的过一遍,在此留下记录,便于后期查阅。
当然,遇到问题,查阅资料解决问题是第一步,随后才是根据兴趣去完善建立这一块的知识体系,希望自己可以坚持下去,将自己的知识体系建立起来.
LearnOpengl网站是opengl学习的一个比较经典的网站资料,决定先由此开始。
https://learnopengl-cn.github.io/
讲真,学习OpenGL确实是存在一定的门槛的,之前opengl的各种资料书记以及网站视频,包括LearnOpengl网站我都看过,但看完之后感觉没收获什么,这一次再次捡起来,发现以前看不懂或者跑不通的地方,这次居然都能看懂,也很顺利的运行,同时吸取之前学习的教训,决定这次把自己学习到的东西记录下来,供自己和他人参考。
OpenGL关键点
- OpenGL本身是一种规范,仅定义接口的输入输出及希望实现的功能,但不做具体实现;
- 通常显卡厂商匹配各自生产的显卡硬件,基于通用的Opengl规范进行具体API的开发;因此,opengl的具体实现随同显卡驱动的安装而一起安装。但有个疑问:实际中opengl32.lib库是随着操作系统的SDK自带的,比如微软的VS安装时,会自动安装opengl32.lib,那程序最终使用的是SDK中的opengl库,还是显卡驱动中的opengl实现呢?这个问题有了解的朋友可以在评论区一起讨论下
- Opengl3.3规范的定义,要求使用核心模式渲染,增加了开发的灵活性。
- OpenGL本身是一个巨大的状态机,其规范定义了很多状态量以及状态函数(状态设置函数和状态使用函数),其所有的状态被称作OpenGL上下文;
- Opengl引入Object对象,来对多个状态选项进行组合封装,比如窗口对象,可以包含宽高、标题等状态属性。
Opengl辅助库
opengl提供的接口本身只是处理图像,对于图像显示在哪,接口如何调用等都没有定义,需要借助第三方的库实现,主要有两件事入药第三方库辅助完成:
- 显示窗口及窗体上下文的创建
创建窗口一般是调用操作系统的API,因操作系统的不同会有所差异,这里推荐使用GLFW库实现窗口的创建。
GLFW网站:https://www.glfw.org/download.html。 可直接下载编译好的二进制库
包含头文件#include <GLFW\glfw3.h>,引入库文件:glfw3.lib/glfw3.dll - Opengl接口的调用
因显卡驱动的版本很多,opengl函数的地址无法在编译阶段确定,需要在运行时查询。而动态获取函数指针的方法因操作系统也是有所差异的且比较繁琐,因此引入GLAD三方库实现opengl函数指针的动态获取;
GLAD网站:https://glad.dav1d.de/
注意:他是一个在线服务,可选择语言、模式、opengl版本获取对应的GLAD库及头文件
头文件引用:#include <glad/glad.h>
库文件引用:无库文件,但提供一个glad.c的源文件,可直接添加到自己的应用工程中进行编译 - opengl32.lib库引用
opengl函数调用,windows下需要引入opengl32.lib/opengl32.dll,该库在安装VS的SDK时自动安装也添加到了环境变量中,可直接引用;若未安装SDK,直接安装显卡驱动,是否会有这个库?可评论区留言讨论
第一个窗口及三角形
几点注意的地方:
- glad.h文件包含了opengl头文件的引用,需要放在glfw3.h头文件前面引用
- 显示一个GLFW窗口主要包含以下几步:
- 初始化GLFW环境
- 配置GLFW状态参数,如设置使用的Opengl版本,模式等
- 创建窗口对象
- 将窗口对象的上下文设置为当前主线程的上下文
- 初始化GLAD
- 调用opengl视口设置函数glViewPort设置图像绘制的区域(小于等于窗口大小)
- 构建渲染循环(图像操作、交换缓冲区、检查事件等)
- 调用glfw函数处理窗口、鼠标、键盘事件
- 循环结束时调用glfw函数回收资源
- 调用opengl接口绘制三角形
- opengl采用图形渲染管线将三位坐标转换为最终的屏幕像素
- 管线中的每个阶段都是在GPU上独立运行的小程序,小程序被称作着色器,由着色器语言(GLSL)语言编写的,一般开发者主要会对顶点着色器及片段着色器进行编程
- opengl定义了几个对象VAO/VBO/EBO,用于管理GPU上的内存的,VBO用户管理顶点数据的(包含多个属性),EBO是管理索引数据的,为了解决重复顶点的问题,VAO则是为了更高效的管理多个顶点属性数据而设计的,核心模型要求使用VAO;
- opengl数据操作流程如下
(1)生成缓冲区对象
(2)绑定到指定目标
(3)拷贝数据至GPU显存
(4)指定顶点数据解析方式
(5)启用指定顶点属性 - 着色器操作流程
(1) 创建着色器
(2) 绑定着色器源码
(3) 编译着色器
(4) 创建着色器程序
(5) 附加顶点着色器和片段着色器至着色器程序
(6) 链接着色器程序
(7) 使用着色器程序
(8) 删除着色器
(9) 注意只有使用的着色器程序,才可以操作着色器中的uniform变量
工作流源码:
// 1. 使用GLFW库函数创建窗口
// 1.1 初始化GLFW
glfwInit();
// 1.2 配置GLFW参数
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); // 告诉GLFW使用的Opengl版本为3.3的核心模式,基于这些参数的设置,GLFW在创建Opengl上下文时会做出适当的调整
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); // 同时,也限制了电脑Opengl低于3.3,程序无法运行
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
// 1.3 创建用于显示的窗口
GLFWwindow* window = glfwCreateWindow(800, 600, "LearnOpenGL", NULL, NULL);
if (window == NULL)
{
std::cout << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
// 通知GLFW将window窗口的上下文设置为当前线程的主上下文了。
glfwMakeContextCurrent(window);
// 2.0 初始化GLAD
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cout << "Failed to initialize GLAD" << std::endl;
return -1;
}
glViewport(0, 0, 800, 600); // 0,0表示左下角坐标,800---w;600--h
// 注册回调函数
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
// 构建三角形
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
//VAO用于更方便的管理VBO中的多个顶点属性
unsigned int VAO;
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);
// 创建opengl的顶点缓冲对象,我理解相当于在GPU显存上创建了一块内存
unsigned int VBO;
glGenBuffers(1, &VBO);
// 指定生成的缓冲区为数组类型
glBindBuffer(GL_ARRAY_BUFFER, VBO);
// 拷贝顶点数据到VBO对应的缓冲区
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 告诉GPU如何解析VBO中的顶点数据
// 0 表示位置属性的id
// 3 表示位置属性包含几个元素
// GL_FLOAT 指定每个元素的类型
// GL_FALSE 不希望数据被标准化
// 3 * sizeof(float) 第一个位置属性到下一个位置属性中间间隔的字节数
//0 -- 位置数据在缓冲中起始位置的偏移量(Offset)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0); // 启动顶点位置属性0
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
// 创建顶点和片段着色器处理拷贝至VBO中的顶点数据
MyShader ourShader("vertexShader.vs", "fragmentShader.fs");
// 渲染循环
while (!glfwWindowShouldClose(window)) // 检查GLFW是否被要求退出
{
processInput(window);
// 用指定颜色清屏(当前只关注颜色缓冲区)
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
ourShader.Use();
// 3. 绘制物体
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
glfwSwapBuffers(window); // 交换颜色缓冲,用来绘制,并且将会作为输出显示在屏幕上
glfwPollEvents(); // 检查有没有触发什么事件(比如键盘输入、鼠标移动等)
}
glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
ourShader.Destroy();
glfwTerminate();
return 0;
着色器封装类:
#include "MyShader.h"
MyShader::MyShader(const char* vertexPath, const char* fragmentPath)
{
// 1. 从文件路径中获取顶点/片段着色器
std::string vertexCode;
std::string fragmentCode;
std::ifstream vShaderFile;
std::ifstream fShaderFile;
// 保证ifstream对象可以抛出异常:
vShaderFile.exceptions(std::ifstream::failbit | std::ifstream::badbit);
fShaderFile.exceptions(std::ifstream::failbit | std::ifstream::badbit);
try
{
// 打开文件
vShaderFile.open(vertexPath);
fShaderFile.open(fragmentPath);
std::stringstream vShaderStream, fShaderStream;
// 读取文件的缓冲内容到数据流中
vShaderStream << vShaderFile.rdbuf();
fShaderStream << fShaderFile.rdbuf();
// 关闭文件处理器
vShaderFile.close();
fShaderFile.close();
// 转换数据流到string
vertexCode = vShaderStream.str();
fragmentCode = fShaderStream.str();
}
catch (std::ifstream::failure e)
{
std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;
}
const char* vShaderCode = vertexCode.c_str();
const char* fShaderCode = fragmentCode.c_str();
// 2. 编译着色器
unsigned int vertex, fragment;
int success;
char infoLog[512];
// 顶点着色器
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &vShaderCode, NULL);
glCompileShader(vertex);
// 打印编译错误(如果有的话)
glGetShaderiv(vertex, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(vertex, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
};
// 片段着色器
fragment = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragment, 1, &fShaderCode, NULL);
glCompileShader(fragment);
// 打印编译错误(如果有的话)
glGetShaderiv(fragment, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(fragment, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
};
// 着色器程序
ID = glCreateProgram();
glAttachShader(ID, vertex);
glAttachShader(ID, fragment);
glLinkProgram(ID);
// 打印连接错误(如果有的话)
glGetProgramiv(ID, GL_LINK_STATUS, &success);
if (!success)
{
glGetProgramInfoLog(ID, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}
// 删除着色器,它们已经链接到我们的程序中了,已经不再需要了
glDeleteShader(vertex);
glDeleteShader(fragment);
}
void MyShader::Use()
{
glUseProgram(ID);
}
void MyShader::Destroy()
{
glDeleteProgram(ID);
}
void MyShader::SetBool(const std::string& name, bool value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), (int)value);
}
void MyShader::SetInt(const std::string& name, int value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), value);
}
void MyShader::SetFloat(const std::string& name, float value) const
{
glUniform1f(glGetUniformLocation(ID, name.c_str()), value);
}
顶点着色器
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aTexCoord;
out vec3 ourColor;
out vec2 TexCoord;
void main()
{
gl_Position = vec4(aPos, 1.0);
ourColor = aColor;
TexCoord = aTexCoord;
}
片段着色器
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
in vec2 TexCoord;
uniform sampler2D ourTexture;
void main()
{
FragColor = texture(ourTexture, TexCoord);
}
纹理
- 在顶点数据中加入纹理坐标属性,用于指定每个顶点对应纹理的那个部分
- 顶点之间部分的纹理通过对纹理图片进行插值获取
- 将顶点数据的纹理坐标属性传递给顶点着色器
a. 2D纹理坐标系为左下角为(0,0)原点,右上角为(1,1),x,y坐标范围均为[0,1]
b. 根据纹理坐标获取纹理的颜色叫做采样
-
- 使用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"
加载jpg:
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
- 将纹理对象传递给片段着色器
a. 在片段着色器中添加uniform变量,变量类型为GLSL内建类型(Sampler2D/Sampler1D/Sampler3D)
uniform sampler2D texture2;
b. 激活纹理单元(GL_TEXTURE0~GL_TEXTURE16),并绑定纹理对象
glBindVertexArray(VAO);
glActiveTexture(GL_TEXTURE0);
c. 设置uniform纹理采样器变量,从0开始顺序设置
ourShader.SetInt("texture1", 0);
ourShader.SetInt("texture1", 1);
d. 使用内建变量texture采样纹理颜色
texture(texture1, coorTex)
- 颜色混合
// 顶点颜色与纹理颜色混合
texture(texture1, coorTex) * vec4(aColor, 1.0);
// 多个纹理颜色混合
mix(texture(texture1, coorTex), texture(texture2, coorTex), 0.2);
变换
- 弄清楚几个数学概念:向量、矩阵、点乘、叉乘、旋转平移缩放矩阵表达、矩阵组合
- 齐次坐标补齐原则:点坐标补一个1,向量补一个0
- 使用glm操作矩阵及向量运算
网址:https://github.com/icaven/glm/tags
直接获取glm头文件包即可使用
引入头文件
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp
矩阵操作
// 构建4*4矩阵,glm0.9.9版本以后,默认构建全零矩阵,若想构建单位阵,需要设置 glm::mat4 trans = glm::mat4(1.0f)
glm::mat4 trans;
// 构建平移矩阵
trans = glm::translate(trans, glm::vec3(0.5f, -0.5f, 0.0f));
// 构建旋转矩阵(0.9.9之前版本的glm,传入的角度应该是角度值),之后的为弧度制
trans = glm::rotate(trans,90, glm::vec3(0.0, 0.0, 1.0));
// 构建缩放矩阵
trans = glm::scale(trans, glm::vec3(0.5, 0.5, 0.5));
将二维平面设置成透视效果
物体三维坐标转换至屏幕坐标需要经历一些过度的坐标系
局部坐标系:
世界坐标系: 模型转换矩阵
相机坐标系: 视图转换矩阵
裁剪坐标系: 投影转换矩阵
屏幕坐标系:
// 0.9.9之前的glm版本旋转角度传入的应该为角度
// 构建模型转换矩阵
glm::mat4 model;
model = glm::rotate(model, (float)-55.0, glm::vec3(1.0, 0.0, 0.0));
// 构建试图矩阵(// 注意,我们将矩阵向我们要进行移动场景的反方向移动。)
glm::mat4 view;
view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f));
// 构建投影矩阵
glm::mat4 projection;
projection = glm::perspective(45.0f, (float)800.0 / (float)600.0, 0.1f, 100.0f);
深度测试
通过一系列顶点绘制多个三角形组成立方体显示,会发现立方体显示很奇怪,这是因为在绘制三角形时,存在三角形覆盖,导致某些不应被覆盖的三角形被遮挡。
可以通过开启深度缓冲,没每一帧渲染时清除深度缓冲区的方式修改
glEnable(GL_DEPTH_TEST);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
存储所有顶点的深度信息的内存叫做Z缓冲
深度是储存在每个片段里面的,当片段着色器需要输出颜色时,OpenGL会将他的深度值和Z缓冲进行比较,如果当前片段在其他片段之后,他将会被丢弃,否则将会覆盖,这叫做深度测试
深度测试默认是关闭的,需要手动开启.
摄像机
OpenGL本身并没有相机(Camera)的概念,它是通过将场景中的物体往相反的方向变化模拟出相机的效果的;这里描述的相机为FPS风格的摄像机。
相机坐标系
- 以相机位置为远点
- 以视线方向的反方向为Z轴正方向
- 相机向上的方向与Z轴正方向叉乘得到的方向为X轴正方向
- Z轴正方向与X轴正方向叉乘得到的方向为Y轴正方向
- 可以通过相机坐标系的X,Y,Z方向在世界坐标系下的方向向量以及相机在世界坐标系下的坐标构建出一个视图矩阵
- glm提供glm::LookAt函数可计算出一个视图矩阵,需要传入相机的位置,LookAt点的位置以及相机向上的方向向量
- 通过更新相机位置、视线方向(更新视图矩阵)以及视场角(更新投影矩阵)可以实现相机运动的交互模拟
// 更新相机位置
void processInput1(GLFWwindow* window)
{
float cameraSpeed = 0.25f * deltaTime; // adjust accordingly
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(window, true);
if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
cameraPos += cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
cameraPos -= cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
}
// 控制不同主机上相机更新的速度一致
float currentFrame = static_cast<float>(glfwGetTime());
// std::cout << "frametime is " << currentFrame << std::endl;
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
// 更新视图矩阵
view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
// 更新视线方向
bool firstMouse = true;
float lastX = 400.0f;
float lastY = 300.0f;
float yaw = -90.0;
float pitch = 0.0;
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;
float sensitivity = 0.1f; // change this value to your liking
xoffset *= sensitivity;
yoffset *= sensitivity;
yaw += xoffset;
pitch += yoffset;
// make sure that when pitch is out of bounds, screen doesn't get flipped
if (pitch > 89.0f)
pitch = 89.0f;
if (pitch < -89.0f)
pitch = -89.0f;
glm::vec3 front;
front.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
front.y = sin(glm::radians(pitch));
front.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));
cameraFront = glm::normalize(front);
}
glm::mat4 view;
view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
// 更新视场角
float fov = 45.0f;
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
fov -= (float)yoffset;
if (fov < 1.0f)
fov = 1.0f;
if (fov > 45.0f)
fov = 45.0f;
}
glm::mat4 projection;
projection = glm::perspective((float)fov, (float)800.0 / (float)600.0, 0.1f, 100.0f);