DiveintoCpp:from OpenGL(一):入门与光照

起因是看到有人在Reddit上推荐通过SDL这种库上手学习C/Cpp,我看了一下发现很不错,于是进一步看了看了解了SFML.最后决定通过写写openGL学习一下c/c++.

前置知识

绘图API包括OpenGL,Vulkan,DirectX,Metal等等,其中

  • DX11、DX12微软公司在Windows系统上所开发的3D图形编程接口
  • OpenGL:OpenGL是一套跨语言、跨平台的API,它的实现存在于Windows、部分UNIX和Mac OS,这些实现一般由显卡厂商提供,而且非常依赖于该厂商提供的硬件。
  • Vulkan:下一代的OpenGL,相比之下,Vulkan更接近底层,并且能很好地分配CPU核心来执行并行任务
  • Metal:Metal API 由苹果公司提供,它旨在为iOSiPadOSmacOStvOS上的应用程序提供对GPU硬件的低级访问来提高性能,它与Vulkan、DX12都属于低级别的API

此外的SDL与SFML是多媒体开发库,包含了绘图和网络,音频等功能,比较全面.

SDL

SDL是一个跨平台的多媒体开发库,用于游戏开发和其他多媒体应用。以下是SDL的特点:

2D图形渲染: SDL提供了2D图形渲染的功能,虽然不如SFML那样高级,但仍然足够满足一般的2D游戏需求。
音频: SDL支持音频播放,但相较于SFML而言,其音频功能较为基础。
窗口和事件处理: 提供了创建窗口、处理鼠标、键盘事件的功能。
低级硬件访问: SDL也提供了对硬件的低级访问,使得开发者可以更灵活地操作硬件。

SFML

SFML是一个现代、面向对象的多媒体库,专注于2D游戏开发和多媒体应用程序。以下是SFML的特点:

2D图形渲染: SFML提供了简单易用的2D图形渲染接口,使得创建2D游戏非常容易。
音频: SFML支持音频播放和音频捕获功能,可以用来添加音乐、音效等。
窗口和事件处理: 提供了创建窗口、处理鼠标、键盘事件的功能。
网络: SFML包含网络模块,允许游戏之间进行网络通信。

此外还有主要关注界面的ImGUI,raylib以及tui ArthurSonzogni/FTXUI: :computer: C++ Functional Terminal User Interface. :heart:

环境搭建

使用C++开发,环境搭建不是小问题.Linux上配置makefile或者CMake添加lib和dll库,而windows还是使用vs添加lib和头文件即可.下面使用Windows搭建环境.

首先安装GLFW,它之前是freeglut和glut

GLFW是一个专门针对OpenGL的C语言库,它提供了一些渲染物体所需的最低限度的接口。它允许用户创建OpenGL上下文、定义窗口参数以及处理用户输入

从官网下载Download | GLFW,然后将头文件目录和lib库目录配置到vs的C++配置中,再将dll放到执行程序所在目录.

如果你是Windows平台,opengl32.lib已经包含在Microsoft SDK里了,它在Visual Studio安装的时候就默认安装了。由于这篇教程用的是VS编译器,并且是在Windows操作系统上,我们只需将opengl32.lib添加进连接器设置里就行了。值得注意的是,OpenGL库64位版本的文件名仍然是opengl32.lib(和32位版本一样),虽然很奇怪但确实如此。

事实上这样就能执行一些程序了.

因为OpenGL只是一个标准/规范,具体的实现是由驱动开发商针对特定显卡实现的。由于OpenGL驱动版本众多,它大多数函数的位置都无法在编译时确定下来,需要在运行时查询。所以任务就落在了开发者身上,开发者需要在运行时获取函数地址并将其保存在一个函数指针中供以后使用

静态链接和动态链接的相关介绍C++静态库与动态库 | 菜鸟教程 (runoob.com),c++引入第三方库就是使用这些库.如果你想的是使用C++开发成熟的应用,那还是推荐使用Qt.这里的库更多的还是去造轮子或者是学习的,毕竟c++就是干这个的

基础知识

​ 此外还需要其他工具,因为OpenGL只是一个标准/规范具体的实现是由驱动开发商针对特定显卡实现的。由于OpenGL驱动版本众多,它大多数函数的位置都无法在编译时确定下来,需要在运行时查询。所以任务就落在了开发者身上,开发者需要在运行时获取函数地址并将其保存在一个函数指针中供以后使用。取得地址的方法因平台而异

​ OpenGL被认为是一个API(Application Programming Interface, 应用程序编程接口),包含了一系列可以操作图形、图像的函数。然而,OpenGL本身并不是一个API,它仅仅是一个由Khronos组织制定并维护的规范(Specification)。

OpenGL规范严格规定了每个函数该如何执行,以及它们的输出值。至于内部具体每个函数是如何实现(Implement)的,将由OpenGL库的开发者自行决定。因为OpenGL规范并没有规定实现的细节,具体的OpenGL库允许使用不同的实现,只要其功能和结果与规范相匹配(亦即,作为用户不会感受到功能上的差异)。

早期的OpenGL使用立即渲染模式(Immediate mode,也就是固定渲染管线),这个模式下绘制图形很方便。OpenGL的大多数功能都被库隐藏起来,开发者很少有控制OpenGL如何进行计算的自由。而开发者迫切希望能有更多的灵活性。随着时间推移,规范越来越灵活,开发者对绘图细节有了更多的掌控。立即渲染模式确实容易使用和理解,但是效率太低。因此从OpenGL3.2开始,规范文档开始废弃立即渲染模式,并鼓励开发者在OpenGL的核心模式(Core-profile)下进行开发,这个分支的规范完全移除了旧的特性

立即渲染模式从OpenGL实际运作中抽象掉了很多细节,因此它在易于学习的同时,也很难让人去把握OpenGL具体是如何运作的。

现代函数要求使用者真正理解OpenGL和图形编程,提供了更多的灵活性,更高的效率,更重要的是可以更深入的理解图形编程。

OpenGL自身是一个巨大的状态机(State Machine):一系列的变量描述OpenGL此刻应当如何运行。OpenGL的状态通常被称为OpenGL上下文(Context)。通常使用如下途径去更改OpenGL状态:设置选项,操作缓冲。最后使用当前OpenGL上下文来渲染。

假设当我们想告诉OpenGL去画线段而不是三角形的时候,我们通过改变一些上下文变量来改变OpenGL状态,从而告诉OpenGL如何去绘图。一旦我们改变了OpenGL的状态为绘制线段,下一个绘制命令就会画出线段而不是三角形。

当使用OpenGL的时候,会遇到一些状态设置函数(State-changing Function),这类函数将会改变上下文。以及状态使用函数(State-using Function),这类函数会根据当前OpenGL的状态执行一些操作。只要你记住OpenGL本质上是个大状态机,就能更容易理解它的大部分特性。

窗口

只是OpenGL是不够的,需要对应的窗口系统,创建窗口,处理键盘、鼠标等设备事件.

常用GLFW作为管理窗口的库。

经常使用的GLFW函数如下

初始化

  1. glfwInit(): Initializes the GLFW library.
  2. glfwTerminate(): Terminates the GLFW library.

创建窗口

  1. glfwCreateWindow(width, height, title, monitor, share): Creates a new window with the specified width, height, title, and monitor.
  2. glfwCreateWindowSurface(window, share): Creates a new window surface for the specified window.

窗口管理

  1. glfwMakeContextCurrent(window): Makes the specified window’s OpenGL context current.
  2. glfwSwapBuffers(window): Swaps the front and back buffers of the specified window.
  3. glfwPollEvents(): Processes any pending events for the specified window.
  4. glfwWaitEvents(): Waits for any pending events for the specified window.
  5. glfwWindowShouldClose(window): Returns whether the specified window should be closed.

输入处理

  1. glfwGetKey(window, key): Returns the state of the specified key.
  2. glfwGetMouseButton(window, button): Returns the state of the specified mouse button.
  3. glfwGetCursorPos(window, x, y): Returns the current cursor position.
  4. glfwGetJoystickAxes(window, joystick, axes): Returns the state of the specified joystick axes.

错误处理

  1. glfwGetError(): Returns the last error message.
  2. glfwGetErrorString(error): Returns the error message for the specified error code.

其他

  1. glfwSetWindowPos(window, x, y): Sets the position of the specified window.
  2. glfwSetWindowSize(window, width, height): Sets the size of the specified window.
  3. glfwSetWindowTitle(window, title): Sets the title of the specified window.
  4. glfwSetWindowIcon(window, icons): Sets the icon of the specified window.

GLFW本身与OpenGL无关,但OpenGL需要窗口渲染. 一个经典的例子是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int main() {
glfwInit(); // 初始化GLFW
glfwWindowHint(); // 配置GLFW,包括版本等信息
...
auto window = glfwCreateWindow(); // 创建窗口
glfwMakeContextCurrent(window); // 设置为当前线程的主上下文
// 注册事件处理
glfwsetFreamebufferSizeCallback();
...
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) //通过glad调用不同版本的opengl
{
std::cout << "Failed to initialize GLAD" << std::endl;
return -1;
}
// 设置一些回调
glfwSetFramebufferSizeCallback()
while(!glfwWindowShouldClose(window))
// 进入渲染循环
{
// 输入
processInput(window);
glfwSwapBuffers(window); //交换buffer
glfwPollEvents(); // 处理窗口事件
}
glfwTerminate(); //安全退出
}

渲染pipeline

  • 顶点数组对象:Vertex Array Object,VAO
  • 顶点缓冲对象:Vertex Buffer Object,VBO
  • 元素缓冲对象:Element Buffer Object,EBO 或 索引缓冲对象 Index Buffer Object,IBO

图形渲染管线接受一组3D坐标,然后把它们转变为你屏幕上的有色2D像素输出。图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。所有这些阶段都是高度专门化的(它们都有一个特定的函数),并且很容易并行执行。正是由于它们具有并行执行的特性,当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做着色器(Shader)

有些着色器可以由开发者配置,因为允许用自己写的着色器来代替默认的,所以能够更细致地控制图形渲染管线中的特定部分了。因为它们运行在GPU上,所以节省了宝贵的CPU时间。OpenGL着色器是用OpenGL着色器语言(OpenGL Shading Language, GLSL)写成的

img

​ 首先以数组的形式传递3个3D坐标作为图形渲染管线的输入,用来表示一个三角形,这个数组叫做顶点数据(Vertex Data);顶点数据是一系列顶点的集合。一个顶点(Vertex)是一个3D坐标的数据的集合。而这样一个顶点的数据是用顶点属性(Vertex Attribute)表示的,理论上可以包含任何想用的数据.

图形渲染管线的第一个部分是顶点着色器(Vertex Shader),它把一个单独的顶点作为输入。顶点着色器主要的目的是把3D坐标转为另一种3D坐标,同时顶点着色器允许我们对顶点属性进行一些基本处理

第二个阶段,顶点着色器阶段的输出可以选择性地传递给几何着色器(Geometry Shader)。几何着色器将一组顶点作为输入,这些顶点形成图元,并且能够通过发出新的顶点来形成新的(或其他)图元来生成其他形状

​ 第三个阶段,图元装配(Primitive Assembly)阶段将顶点着色器(或几何着色器)输出的所有顶点作为输入(如果是GL_POINTS,那么就是一个顶点),并将所有的点装配成指定图元的形状

​ 图元装配阶段的输出会被传入光栅化阶段(Rasterization Stage),这里它会把图元映射为最终屏幕上相应的像素生成供片段着色器(Fragment Shader)使用的片段(Fragment)。在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。

​ 片段着色器的主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色

​ 在所有对应颜色值确定以后,最终的对象将会被传到最后一个阶段,叫做Alpha测试和混合(Blending)阶段。这个阶段检测片段的对应的深度(和模板(Stencil))值,用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查alpha值(alpha值定义了一个物体的透明度)并对物体进行混合(Blend)。所以,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同

​ 可以看到,图形渲染管线非常复杂,它包含很多可配置的部分。然而,对于大多数场合,我们只需要配置顶点和片段着色器就行了。几何着色器是可选的,通常使用它默认的着色器就行了.

在现代OpenGL中必须定义至少一个顶点着色器和一个片段着色器(因为GPU中没有默认的顶点/片段着色器)

具体阶段介绍如下。

输入顶点

​ OpenGL不是简单地把所有的3D坐标变换为屏幕上的2D像素;OpenGL仅当3D坐标在3个轴(x、y和z)上-1.0到1.0的范围内时才处理它。所有在这个范围内的坐标叫做标准化设备坐标(Normalized Device Coordinates),此范围内的坐标最终显示在屏幕上(在这个范围以外的坐标则不会显示)。

​ 一旦顶点坐标已经在顶点着色器中处理过,它们就应该是标准化设备坐标了,标准化设备坐标是一个x、y和z值在-1.0到1.0的一小段空间。任何落在范围外的坐标都会被丢弃/裁剪,不会显示在你的屏幕上。

​ 通过使用由glViewport函数提供的数据,进行视口变换(Viewport Transform),标准化设备坐标(Normalized Device Coordinates)会变换为屏幕空间坐标(Screen-space Coordinates)。所得的屏幕空间坐标又会被变换为片段输入到片段着色器中。

可以通过glVertexAttribPointer对数据进行normal,使得顶点着色器处理.

buffers

在 OpenGL 中,缓冲区用于存储数据,比如顶点坐标颜色信息纹理坐标。缓冲区可以帮助我们更高效地处理和渲染图形。 glGenBuffers 是一个 OpenGL 函数,用于生成一个或多个缓冲对象的唯一标识符(ID)。这些 ID 后续将用于引用和操作这些缓冲对象。

唯一性:每个缓冲对象都有一个唯一的 ID,允许 OpenGL 知道我们在操作哪个缓冲。

管理数据:通过生成和管理缓冲对象,我们可以高效地传输和存储图形数据

1
2
3
4
GLuint b;
GLuint b_arr[10];
glGenBuffers(1,&b);
glGenBuffers(10,&b_arr);

创建缓冲后,需要绑定缓冲对象(设置缓冲属性),缓冲对象类型包括

  1. 顶点缓冲对象(Vertex Buffer Object, VBO)
  • 用途:存储顶点数据(如位置、法线、纹理坐标等)。
  • 类型GL_ARRAY_BUFFER
  • 示例:通常用于传递顶点坐标到顶点着色器。
  1. 索引缓冲对象(Element Buffer Object, EBO)
  • 用途:存储索引数据,用于重用顶点,减少内存使用。
  • 类型GL_ELEMENT_ARRAY_BUFFER
  • 示例:允许通过索引绘制图形,避免重复存储相同的顶点数据。
  1. 变换反馈缓冲对象(Transform Feedback Buffer Object)
  • 用途:用于存储从着色器输出的变换反馈数据。
  • 类型GL_TRANSFORM_FEEDBACK_BUFFER
  • 示例:可以捕获顶点着色器的输出,用于后续处理或绘制。
  1. 颜色缓冲对象(Color Buffer Object)
  • 用途:用于存储帧缓冲中的颜色数据。
  • 类型GL_COLOR_ATTACHMENTn
  • 示例:用于多重渲染目标(MRT)或后期处理
  1. 深度缓冲对象(Depth Buffer Object)
  • 用途:用于存储深度信息,帮助实现深度测试。
  • 类型GL_DEPTH_ATTACHMENT
  • 示例:用于确定哪个物体在前面,哪个在后面。
  1. 模板缓冲对象(Stencil Buffer Object)
  • 用途:用于存储模板测试的信息。
  • 类型GL_STENCIL_ATTACHMENT
  • 示例:用于实现复杂的图形效果,如阴影和反射。
  1. Pixel Buffer Object (PBO)
  • 用途:用于异步传输图像数据,减少 CPU 和 GPU 之间的等待时间。
  • 类型GL_PIXEL_UNPACK_BUFFERGL_PIXEL_PACK_BUFFER
  • 示例:用于加载纹理或读取渲染结果。

绑定之后,使用的在这个缓冲属性上的调用都会用来配置当前绑定的缓冲数据,这样就能向缓冲区传输数据了. glBufferData将 CPU 中的数据复制到 GPU 的缓冲对象中,使得图形渲染时可以快速访问这些数据。

接着可以设置这些数据的解读方式,glVertexAttribPointer用于定义顶点属性,告诉 OpenGL 如何从缓冲对象中解析顶点数据,使得顶点着色器能够正确读取这些数据。通常在设置顶点缓冲对象(VBO)后使用,目的是将缓冲中的数据关联到顶点着色器的属性。

着色器编写

OpenGL中必须由开发者写的着色器是顶点和片段着色器,前者主要设置顶点属性(包括坐标等),后者设置颜色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  // 顶点着色器
auto vertexShaderSource = R"(#version 400 core
layout(location = 0) in vec3 aPos;
void main(){
gl_Position = vec4(aPos.x,aPos.y,aPos.z,1.0f);
})";
GLuint shader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(shader, 1, &vertexShaderSource, NULL);
glCompileShader(shader);
int success;
char infoLog[512];
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
if (!success) {
glGetShaderInfoLog(shader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::COMPILATION_FAILED\n" << infoLog << std::endl;
}
return shader;

上面是一个顶点着色器编写,使用GLSL. 主要是声明读取的GL_ARRAY_BUFFER位置为0(layout(location = 0))

将着色器链接成程序对象(链接后可以删除shader),并在渲染中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
// 链接程序得到着色器程序对象
unsigned int shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
while() {
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements();
}
glDeleteProgram(shaderProgram);

绘制图形

使用glDrawArraysglDrawElements等指令绘制图元,通过顶点数组直接使用当前绑定的顶点缓冲对象(VBO)中的数据或EBO中的索引来渲染图形.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
#define GLFW_INCLUDE_NONE
#include <GLFW/glfw3.h>
#include <glad/glad.h>

#include <iostream>

void framebuffer_size_callback(GLFWwindow* window, int width, int height) {
glViewport(0, 0, width, height);
}

int main() {
if (!glfwInit()) {
std::cerr << "Failed to initialize GLFW" << std::endl;
return -1;
}
glfwInitHint(GLFW_VERSION_MAJOR, 4);
glfwInitHint(GLFW_VERSION_MAJOR, 0);
glfwInitHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
#ifdef __APPLE__
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif

const int width = 800;
const int height = 600;
auto title = "A little OpenGL game";
auto window = glfwCreateWindow(width, height, title, nullptr, nullptr);
if (!window) {
std::cerr << "Failed to create window" << std::endl;
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);

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

// 着色器编写
auto vertexShaderSource = R"(
#version 400 core
layout(location = 0) in vec3 aPos;
out vec4 vertexColor;
void main() {
gl_Position = vec4(aPos,1.0f);
vertexColor = vec4(.5f,.0f,.0f,1.0f);
}
)";
GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, nullptr);
glCompileShader(vertexShader);

auto fragmentShaderSource = R"(
#version 400 core
out vec4 FragColor;
in vec4 vertexColor;
void main() {
FragColor = vertexColor;
}
)";
GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, nullptr);
glCompileShader(fragmentShader);

GLuint shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);

glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

GLuint VAO, VBO, EBO;
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);

float vertices[] = {.5f, .0f, .0f, .0f, .5f, .0f, -.5f, .0f, .0f};
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, (void*)0);
glEnableVertexAttribArray(0);

// 渲染loop
while (!glfwWindowShouldClose(window)) {
// process

// 设置背景颜色
glClearColor(.2f, .3f, .3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// 画图
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);

glfwPollEvents();
glfwSwapBuffers(window);
}
glDeleteBuffers(1, &VBO);
glDeleteVertexArrays(1, &VAO);
glDeleteProgram(shaderProgram);
glfwTerminate();
}

GLSL介绍

着色器(Shader)是运行在GPU上的小程序。这些小程序为图形渲染管线的某个特定部分而运行。从基本意义上来说,着色器只是一种把输入转化为输出的程序。着色器也是一种非常独立的程序,因为它们之间不能相互通信;它们之间唯一的沟通只有通过输入和输出。

OpenGL中写着色器的语言叫做GLSL. GLSL主要针对向量和矩阵操作,着色器的开头总是要声明版本,接着是输入和输出变量、uniform和main函数。每个着色器的入口点都是main函数,在这个函数中我们处理所有的输入变量,并将结果输出到输出变量中.

数据类型

和其他编程语言一样GLSL有数据类型可以来指定变量的种类。GLSL中包含C等其它语言大部分的默认基础数据类型:intfloatdoubleuintbool。GLSL也有两种容器类型,分别是向量(Vector)和矩阵(Matrix)

向量

GLSL中的向量是一个可以包含有2、3或者4个分量的容器,分量的类型可以是前面默认基础类型的任意一个。它们可以是下面的形式(n代表分量的数量):

类型含义
vecn包含n个float分量的默认向量
bvecn包含n个bool分量的向量
ivecn包含n个int分量的向量
uvecn包含n个unsigned int分量的向量
dvecn包含n个double分量的向量

大多数时候使用vecn,因为float足够满足大多数要求了。

一个向量的分量可以通过vec.x这种方式获取,这里x是指这个向量的第一个分量。你可以分别使用.x.y.z.w来获取它们的第1、2、3、4个分量。GLSL也允许你对颜色使用rgba,或是对纹理坐标使用stpq访问相同的分量。

Uniform

uniform 数据是一种特殊的变量类型,用于在顶点着色器和片段着色器中传递数据。这些变量的主要特点是它们的值在整个渲染过程中保持不变,即在单个绘制调用中,所有的顶点和片段都能够访问相同的 uniform 数据。

特点

  1. 全局共享:所有着色器阶段(如顶点着色器和片段着色器)都可以访问相同的 uniform 变量。
  2. 只读:在着色器中,uniform 变量是只读的,不能被修改。它们的值只能在 CPU 端(主程序)通过 OpenGL 函数设置。
  3. 跨帧共享:uniform 变量的值可以在多个绘制调用之间保持不变,适合用于传递场景中的全局状态,如光源位置、视图矩阵、材质属性等

uniform在着色器中是只读的,但是可以在渲染循环中设置它的值.

通过glGetUniformLocation,因为uniform数据在着色器中唯一,获得它的值然后直接修改(在cpu端).

1
2
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
while (!glfwWindowShouldClose(window)) {
// process
float timeValue = glfwGetTime();
float greenValue = (std::sin(timeValue) / 2.0f) + 0.5f;
int vertexColorLocation = glGetUniformLocation(shaderProgram,
"outColor"); 设置背景颜色
glClearColor(.2f, .3f, .3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
//画图
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);

glfwPollEvents();
glfwSwapBuffers(window);
}

在渲染中修改uniform值使得不断变化.

设置多个属性

利用glVertexAttribPointer和着色器使用一个顶点的多个属性

1
2
3
4
5
6
7
8
9
10
11
#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置值为 0
layout (location = 1) in vec3 aColor; // 颜色变量的属性位置值为 1

out vec3 ourColor; // 向片段着色器输出一个颜色

void main()
{
gl_Position = vec4(aPos, 1.0);
ourColor = aColor; // 将ourColor设置为我们从顶点数据那里得到的输入颜色
}

设置layout方便后续配置,stride和偏移都需要相应更改.

1
2
3
4
5
6
// 位置属性
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);

实现着色器类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include <fstream>
#include <glad/glad.h>
#include <iostream>
#include <shader.hpp>
#include <sstream>
#include <string>

namespace cg {
Shader::Shader(const char *vertexPath, const char *fragmentPath) {
std::string vertexCode{vertexPath}, fragmentCode{fragmentPath};
std::ifstream vShaderFile{vertexCode};
std::ifstream fShaderFile(fragmentCode);
if (!vShaderFile.is_open() || !fShaderFile.is_open()) {
std::cout << "Failed to open shader file" << std::endl;
}
std::stringstream vShaderStream, fShaderStream;
vShaderStream << vShaderFile.rdbuf();
fShaderStream << fShaderFile.rdbuf();
vShaderFile.close();
fShaderFile.close();
vertexCode = vShaderStream.str();
fragmentCode = fShaderStream.str();
const char *vShaderCode = vertexCode.c_str();
const char *fShaderCode = fragmentCode.c_str();

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::LINK_FAILED\n"
<< infoLog << std::endl;
}
glDeleteShader(vertex);
glDeleteShader(fragment);
}

void Shader::use() { glUseProgram(ID); }
} // namespace cg

纹理

纹理是一个2D图片(甚至也有1D和3D的纹理),它可以用来添加物体的细节;可以想象纹理是一张绘有砖块的纸,无缝折叠贴合到你的3D的房子上,这样房子看起来就像有砖墙外表了。

为了能够把纹理映射(Map)到三角形上,需要指定三角形的每个顶点各自对应纹理的哪个部分。这样每个顶点就会关联着一个纹理坐标(Texture Coordinate),用来标明该从纹理图像的哪个部分采样。之后在图形的其它片段上进行片段插值(Fragment Interpolation)。

使用纹理坐标获取纹理颜色叫做采样(Sampling)。纹理坐标起始于(0, 0),也就是纹理图片的左下角,终止于(1, 1),即纹理图片的右上角(坐标同视窗坐标)纹理采样可以采用几种不同的插值方式

纹理环绕

纹理坐标的范围通常是从(0, 0)到(1, 1),如果把纹理坐标设置在范围之外默认行为是重复这个纹理图像,但OpenGL提供了更多的选择:

环绕方式描述
GL_REPEAT对纹理的默认行为。重复纹理图像。
GL_MIRRORED_REPEAT和GL_REPEAT一样,但每次重复图片是镜像放置的。
GL_CLAMP_TO_EDGE纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。
GL_CLAMP_TO_BORDER超出的坐标为用户指定的边缘颜色。

纹理过滤

纹理坐标不依赖于分辨率(Resolution),它可以是任意浮点值,所以OpenGL需要知道怎样将纹理像素映射到纹理坐标。当有一个很大的物体但是纹理的分辨率很低的时候这就变得很重要了。

过滤方式包括临近和线性过滤等

1
2
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)

环绕和过滤影响texture采样方式,比如当texture中的采样值coords超出[0, 1]时的行为.

多级渐远纹理

每个物体上都有纹理。有些物体会很远,但其纹理会拥有与近处物体同样高的分辨率。由于远处的物体可能只产生很少的片段,OpenGL从高分辨率纹理中为这些片段获取正确的颜色值就很困难,因为它需要对一个跨过纹理很大部分的片段只拾取一个纹理颜色。在小物体上这会产生不真实的感觉,更不用说对它们使用高分辨率纹理浪费内存的问题了

过滤方式描述
GL_NEAREST_MIPMAP_NEAREST使用最邻近的多级渐远纹理来匹配像素大小,并使用邻近插值进行纹理采样
GL_LINEAR_MIPMAP_NEAREST使用最邻近的多级渐远纹理级别,并使用线性插值进行采样
GL_NEAREST_MIPMAP_LINEAR在两个最匹配像素大小的多级渐远纹理之间进行线性插值,使用邻近插值进行采样
GL_LINEAR_MIPMAP_LINEAR在两个邻近的多级渐远纹理之间使用线性插值,并使用线性插值进行采样
1
2
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

一个常见的错误是,将放大过滤的选项设置为多级渐远纹理过滤选项之一。这样没有任何效果,因为多级渐远纹理主要是使用在纹理被缩小的情况下的:纹理放大不会使用多级渐远纹理,为放大过滤设置多级渐远纹理的选项会产生一个GL_INVALID_ENUM错误代码。

加载与创建纹理

使用纹理之前要做的第一件事是把它们加载到我们的应用中。纹理图像可能被储存为各种各样的格式,每种都有自己的数据结构和排列,所以我们如何才能把这些图像加载到应用中呢?一个解决方案是选一个需要的文件格式,比如.PNG,然后自己写一个图像加载器,把图像转化为字节序列。写自己的图像加载器虽然不难,但仍然挺麻烦的,而且如果要支持更多文件格式呢?你就不得不为每种你希望支持的格式写加载器了。

另一个解决方案也许是一种更好的选择,使用一个支持多种流行格式的图像加载库来为我们解决这个问题。比如说stb_image.h库,danoli3/FreeImage: FreeImage library - With Fixes for ALL PLATFORMS,SpartanJ/SOIL2: SOIL2 is a tiny C library used primarily for uploading textures into OpenGL.以及g-truc/gli: OpenGL Image (GLI),ImageMagick/ImageMagick: 🧙‍♂️ ImageMagick 7

1
2
3
4
5
unsigned int texture;
glGenTextures(1,&texture);
glBindTexture(GL_TEXTURE_2D,texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data); // RGBA for with opacity,e.g.png
glGenerateMipmap(GL_TEXTURE_2D);

加载数据后设置uv坐标

1
2
3
4
float vertices[] = {-0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, .0f,  .0f,
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // 左下
0.5f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f,
-0.5f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f};

在片段着色器中使用texture采样与纹理数据

1
2
3
4
5
6
7
8
9
#version 400 core
in vec4 ourColor;
out vec4 FragColor;
in vec2 TexCoord;
uniform sampler2D ourTexture;
void main() {
FragColor = texture(ourTexture,TexCoord);
}

多纹理

一个纹理的位置值通常称为一个纹理单元(Texture Unit)。一个纹理的默认纹理单元是0,它是默认的激活纹理单元。

纹理单元的主要目的是让我们在着色器中可以使用多于一个的纹理

1
2
glActiveTexture(GL_TEXTURE0); // 在绑定纹理之前先激活纹理单元
glBindTexture(GL_TEXTURE_2D, texture);

使用多纹理需要在绑定前激活

坐标系统

局部空间

局部空间是指物体所在的坐标空间,即对象最开始所在的地方。

世界空间

世界空间中的坐标正如其名:是指顶点相对于(游戏)世界的坐标。如果你希望将物体分散在世界上摆放(特别是非常真实的那样),这就是你希望物体变换到的空间。物体的坐标将会从局部变换到世界空间;该变换是由模型矩阵(Model Matrix)实现的。

观察空间

观察空间经常被人们称之OpenGL的摄像机(Camera)(所以有时也称为摄像机空间(Camera Space)或视觉空间(Eye Space))。观察空间是将世界空间坐标转化为用户视野前方的坐标而产生的结果。因此观察空间就是从摄像机的视角所观察到的空间。而这通常是由一系列的位移和旋转的组合来完成,平移/旋转场景从而使得特定的对象被变换到摄像机的前方。这些组合在一起的变换通常存储在一个观察矩阵(View Matrix)里,它被用来将世界坐标变换到观察空间。

裁剪空间

​ 在一个顶点着色器运行的最后,OpenGL期望所有的坐标都能落在一个特定的范围内,且任何在这个范围之外的点都应该被裁剪掉(Clipped)。被裁剪掉的坐标就会被忽略,所以剩下的坐标就将变为屏幕上可见的片段。这也就是裁剪空间(Clip Space)名字的由来。

因为将所有可见的坐标都指定在-1.0到1.0的范围内不是很直观,所以我们会指定自己的坐标集(Coordinate Set)并将它变换回标准化设备坐标系,就像OpenGL期望的那样。

​ 为了将顶点坐标从观察变换到裁剪空间,我们需要定义一个投影矩阵(Projection Matrix),它指定了一个范围的坐标,比如在每个维度上的-1000到1000。投影矩阵接着会将在这个指定的范围内的坐标变换为标准化设备坐标的范围(-1.0, 1.0)

​ 由投影矩阵创建的观察箱(Viewing Box)被称为平截头体(Frustum),每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。将特定范围内的坐标转化到标准化设备坐标系的过程(而且它很容易被映射到2D观察空间坐标)被称之为投影(Projection),因为使用投影矩阵能将3D坐标投影(Project)到很容易映射到2D的标准化设备坐标系中。

​ 一旦所有顶点被变换到裁剪空间,最终的操作——透视除法(Perspective Division)将会执行,在这个过程中我们将位置向量的x,y,z分量分别除以向量的齐次w分量;透视除法是将4D裁剪空间坐标变换为3D标准化设备坐标的过程。这一步会在每一个顶点着色器运行的最后被自动执行。

在这一阶段之后,最终的坐标将会被映射到屏幕空间中(使用glViewport中的设定),并被变换成片段。

​ 将观察坐标变换为裁剪坐标的投影矩阵可以为两种不同的形式,每种形式都定义了不同的平截头体。我们可以选择创建一个正射投影矩阵(Orthographic Projection Matrix)或一个透视投影矩阵

coordinate_systems

所以重要的是三个MVP矩阵,model矩阵将坐标从局部空间转为世界坐标系,视图矩阵将世界坐标系转为摄像机坐标系,最后常常通过透视(projective)转为裁剪空间,在opengl中x,y,z均为[0,1]

Look At矩阵

将世界坐标转为摄像机坐标系需要利用一个Look At矩阵,这个矩阵由新的坐标系的三个轴以及一个平移矩阵构成. 需要摄像机位置,指向点位置和一个向上向量.

位置与视角移动

位置通过改变摄像机位置从而改变x,y,z坐标. 比如z坐标, 当需要沿着摄像机指向的方向(实际为摄像机坐标系z轴的逆向)前进时,就加上摄像机指向向量,要向右走就加上右向量(通过cross(up,direction)),要想上走加上更新的y轴向量。 同时利用deltatime(两帧之间的时间差)得到速度。

而视角可以通过光标在两帧之间x和y坐标变化影响pitch和yaw角,从而影响front direction,也就是摄像机指向方向.

缩放

可以通过改变fov从而改变透视矩阵影响观察的缩放效果

颜色

在现实生活中看到某一物体的颜色并不是这个物体真正拥有的颜色,而是它所反射的(Reflected)颜色。换句话说,那些不能被物体所吸收(Absorb)的颜色(被拒绝的颜色)就是我们能够感知到的物体的颜色。例如,太阳光能被看见的白光其实是由许多不同的颜色组合而成的(如下图所示)。如果我们将白光照在一个蓝色的玩具上,这个蓝色的玩具会吸收白光中除了蓝色以外的所有子颜色,不被吸收的蓝色光被反射到我们的眼中,让这个玩具看起来是蓝色的。下图显示的是一个珊瑚红的玩具,它以不同强度反射了多个颜色。

在代码实现上,给光源单独绑定一个VAO,并写对应的shader. 光源颜色固定,其他物体设置不同颜色.

光照

常用的光照模型是Phong Lighting Model,这个模型将光照分为环境光照漫反射光照镜面光照

img

  • 环境光照(Ambient Lighting):即使在黑暗的情况下,世界上通常也仍然有一些光亮(月亮、远处的光),所以物体几乎永远不会是完全黑暗的。为了模拟这个,我们会使用一个环境光照常量,它永远会给物体一些颜色。
  • 漫反射光照(Diffuse Lighting):模拟光源对物体的方向性影响(Directional Impact)。它是风氏光照模型中视觉上最显著的分量。物体的某一部分越是正对着光源,它就会越亮。
  • 镜面光照(Specular Lighting):模拟有光泽物体上面出现的亮点。镜面光照的颜色相比于物体的颜色会更倾向于光的颜色。

环境关照

简单来说,环境关照是固定的一些颜色.

1
2
3
4
5
6
7
8
void main()
{
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * lightColor;

vec3 result = ambient * objectColor;
FragColor = vec4(result, 1.0);
}

漫反射

漫反射光照使物体上与光线方向越接近的片段能从光源处获得更多的亮度。计算漫反射光照需要:法向量:一个垂直于顶点表面的向量。定向的光线:作为光源的位置与片段的位置之间向量差的方向向量。为了计算这个光线,我们需要光的位置向量和片段的位置向量。

img

法向量

法向量是一个垂直于顶点表面的(单位)向量。由于顶点本身并没有表面(它只是空间中一个独立的点),需要利用它周围的顶点来计算出这个顶点的表面。能够使用一个小技巧,使用叉乘对立方体所有的顶点计算法向量,但是由于3D立方体不是一个复杂的形状,所以我们可以简单地把法线数据手工添加到顶点数据中。

由于法向量不表达空间中的特定位置信息,它没有齐次坐标,因此位移不应该影响法向量。 此外不等比缩放法向量也会使其不再垂直表面,需要使用一个法线矩阵进行修复。法线矩阵被定义为「模型矩阵左上角3x3部分的逆矩阵的转置矩阵

如果没有对物体进行任何缩放操作,则并不真的需要使用一个法线矩阵,而是仅以模型矩阵乘以法线就可以。

1
Normal = mat3(model) * aNormal;

但是如果你会进行不等比缩放,使用法线矩阵去乘以法向量就是必须的了。在顶点着色器中,可以使用inverse和transpose函数自己生成这个法线矩阵,这两个函数对所有类型矩阵都有效。注意还要把被处理过的矩阵强制转换为3×3矩阵,来保证它失去了位移属性以及能够乘以vec3的法向量。

1
Normal = mat3(transpose(inverse(model))) * aNormal;

最好先在CPU上计算出法线矩阵,再通过uniform把它传递给着色器(就像模型矩阵一样)。(这里通过先传入顶点着色器法向量,再通过着色器之间的传参)

注意,大部分的资源都会将法线矩阵定义为应用到模型-观察矩阵(Model-view Matrix)上的操作,但是由于我们只在世界空间中进行操作(不是在观察空间),我们只使用模型矩阵。

镜面光照

和漫反射光照一样,镜面光照也决定于光的方向向量和物体的法向量,但是它也决定于观察方向,例如玩家是从什么方向看向这个片段的。镜面光照决定于表面的反射特性。如果我们把物体表面设想为一面镜子,那么镜面光照最强的地方就是我们看到表面上反射光的地方

img

通过根据法向量翻折入射光的方向来计算反射向量。然后我们计算反射向量与观察方向的角度差,它们之间夹角越小,镜面光的作用就越大。由此产生的效果就是,我们看向在入射光在表面的反射方向时,会看到一点高光。

观察向量是计算镜面光照时需要的一个额外变量,可以使用观察者的世界空间位置和片段的位置来计算它。之后我们计算出镜面光照强度,用它乘以光源的颜色,并将它与环境光照和漫反射光照部分加和。

我们选择在世界空间进行光照计算,但是大多数人趋向于更偏向在观察空间进行光照计算。在观察空间计算的优势是,观察者的位置总是在(0, 0, 0),所以你已经零成本地拿到了观察者的位置。然而,若以学习为目的,在世界空间中计算光照更符合直觉。如果仍然希望在观察空间计算光照的话,需要将所有相关的向量也用观察矩阵进行变换(不要忘记也修改法线矩阵)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#version 400 core
out vec4 FragColor;
in vec3 Normal;
in vec3 FragPos;
uniform vec3 lightColor;
uniform vec3 objectColor;
uniform vec3 lightPos;
uniform vec3 viewPos;
void main() {
// 环境关照
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * lightColor;
// 漫反射光照
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor;
// 镜面反射
vec3 reflectDir = reflect(-lightDir,Normal);
vec3 viewDir = normalize(viewPos - FragPos);
float spec = pow(max(dot(reflectDir, viewDir),0.0), 32);
float specularStrength = 0.5;
vec3 specular = specularStrength * spec * lightColor;
vec3 result = (ambient + diffuse + specular) * objectColor;
FragColor = vec4(result, 1.0);
}

其中pow(.,32)中的32是高光的反光度(Shininess)。一个物体的反光度越高,反射光的能力越强,散射得越少,高光点就会越小

img

在光照着色器的早期,开发者曾经在顶点着色器中实现风氏光照模型。在顶点着色器中做光照的优势是,相比片段来说,顶点要少得多,因此会更高效,所以(开销大的)光照计算频率会更低。然而,顶点着色器中的最终颜色值是仅仅只是那个顶点的颜色值,片段的颜色值是由插值光照颜色所得来的。结果就是这种光照看起来不会非常真实,除非使用了大量顶点。

在顶点着色器中实现的风氏光照模型叫做Gouraud着色(Gouraud Shading),而不是风氏着色(Phong Shading)。由于插值,这种光照看起来有点逊色。风氏着色能产生更平滑的光照效果。

材质

在现实世界里,每个物体会对光产生不同的反应。比如,钢制物体看起来通常会比陶土花瓶更闪闪发光,一个木头箱子也不会与一个钢制箱子反射同样程度的光。有些物体反射光的时候不会有太多的散射(Scatter),因而产生较小的高光点,而有些物体则会散射很多,产生一个有着更大半径的高光点。如果我们想要在OpenGL中模拟多种类型的物体,我们必须针对每种表面定义不同的材质(Material)属性。

当描述一个表面时,可以分别为三个光照分量定义一个材质颜色(Material Color):环境光照(Ambient Lighting)、漫反射光照(Diffuse Lighting)和镜面光照(Specular Lighting)。通过为每个分量指定一个颜色,我们就能够对表面的颜色输出有细粒度的控制了。现在再添加一个反光度(Shininess)分量,结合上述的三个颜色,我们就有了全部所需的材质属性了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#version 330 core
struct Material {
vec3 ambient;
vec3 diffuse;
vec3 specular;
float shininess;
};

uniform Material material;
// c++
lightingShader.setVec3("material.ambient", 1.0f, 0.5f, 0.31f);
lightingShader.setVec3("material.diffuse", 1.0f, 0.5f, 0.31f);
lightingShader.setVec3("material.specular", 0.5f, 0.5f, 0.5f);
lightingShader.setFloat("material.shininess", 32.0f);

将环境光和漫反射分量设置成我们想要让物体所拥有的颜色而将镜面分量设置为一个中等亮度的颜色,因为不希望镜面分量过于强烈。仍将反光度保持为32.

此外也可以设置光的属性,包括位置和三种属性强度.

1
2
3
4
5
6
7
8
struct Light {
vec3 position;
vec3 ambient;
vec3 diffuse;
vec3 specular;
};

uniform Light light;

​ 物体过亮的原因是环境光、漫反射和镜面光这三个颜色对任何一个光源都全力反射。光源对环境光、漫反射和镜面光分量也分别具有不同的强度,用同样的方式影响光源的漫反射和镜面光强度。

​ 因为不希望环境光颜色太过主导。光源的漫反射分量通常被设置为我们希望光所具有的那个颜色,通常是一个比较明亮的白色。镜面光分量通常会保持为vec3(1.0),以最大强度发光。

1
2
3
lightingShader.setVec3("light.ambient",  0.2f, 0.2f, 0.2f);
lightingShader.setVec3("light.diffuse", 0.5f, 0.5f, 0.5f); // 将光照调暗了一些以搭配场景
lightingShader.setVec3("light.specular", 1.0f, 1.0f, 1.0f);

漫反射贴图

一个纹理仅仅是对同样的原理使用了不同的名字:其实都是使用一张覆盖物体的图像,让我们能够逐片段索引其独立的颜色值。在光照场景中,它通常叫做一个漫反射贴图(Diffuse Map),它是一个表现了物体所有的漫反射颜色的纹理图像。

跟纹理类似,不过是颜色部分设置不同. 漫反射贴图设置的是材料部分的环境光和漫反射光(也可以给环境光其他值),本质上是一种简易的上色方式.

给材质的两种属性设置贴图

1
2
3
4
5
6
7
8
9
10
11
12
13
#version 330 core
struct Material {
sampler2D diffuse; // 同样应用在ambient环境光上
vec3 specular;
float shininess;
};
in vec2 Coord;
uniform Material material;
void main() {
vec3 ambient = light.ambient*texture(material.diffuse,Coord).rgb;
....

}

镜面反射贴图

类似的,给材料的镜面光使用纹理. 可以使用一个专门用于镜面高光的纹理贴图。这也就意味着需要生成一个黑白的(如果你想得话也可以是彩色的)纹理,来定义物体每部分的镜面光强度.

​ 镜面高光的强度可以通过图像每个像素的亮度来获取。镜面光贴图上的每个像素都可以由一个颜色向量来表示,比如说黑色代表颜色向量vec3(0.0),灰色代表颜色向量vec3(0.5)。在片段着色器中,我们接下来会取样对应的颜色值并将它乘以光源的镜面强度。一个像素越「白」,乘积就会越大,物体的镜面光分量就会越亮。

​ 漫反射和镜面反射贴图差别是漫反射反应材料本身颜色(反射光的颜色),可以是黄红绿,而镜面反射贴图的颜色反映的是镜面反光效果,使用黑白表示强度更好. 白色部分表示镜面光越明显.

使用PhotoshopGimp之类的工具,将漫反射纹理转换为镜面光纹理还是比较容易的,只需要剪切掉一些部分,将图像转换为黑白的,并增加亮度/对比度就好了

1
2
3
4
5
struct Material {
sampler2D diffuse;
sampler2D specular;
float shininess;
};

通过使用漫反射和镜面光贴图,可以给相对简单的物体添加大量的细节。甚至可以使用法线/凹凸贴图(Normal/Bump Map)或者反射贴图(Reflection Map)给物体添加更多的细节

投光物

目前使用的光照都来自于空间中的一个点。它能带来不错的效果,但现实世界中,我们有很多种类的光照,每种的表现都不同。将光投射(Cast)到物体的光源叫做投光物(Light Caster)。

平行光

当一个光源处于很远的地方时,来自光源的每条光线就会近似于互相平行。不论物体和/或者观察者的位置,看起来好像所有的光都来自于同一个方向。当我们使用一个假设光源处于无限远处的模型时,它就被称为定向光,因为它的所有光线都有着相同的方向,它与光源的位置是没有关系的。

img

1
2
3
4
5
6
7
struct Light {
// vec3 position;
vec3 LightDir; // 定向光/平行光
vec3 ambient;
vec3 diffuse;
vec3 specular;
};

一个方向向量和位置向量如果都是vev4,那么可以根据它们的w判断.

1
2
3
4
>if(lightVector.w == 0.0) // 注意浮点数据类型的误差
// 执行定向光照计算
>else if(lightVector.w == 1.0)
// 根据光源的位置做光照计算(与上一节一样)

如果w=1.0,则是位置光源,否则为定向光

点光源

定向光对于照亮整个场景的全局光源是非常棒的,但除了定向光之外我们也需要一些分散在场景中的点光源(Point Light)。点光源是处于世界中某一个位置的光源,它会朝着所有方向发光,但光线会随着距离逐渐衰减。想象作为投光物的灯泡和火把,它们都是点光源

img

衰减

点光源的光照强度应该与光源和摄像机(观察者)位置均有关系. 在大部分的3D模拟中,都希望模拟的光源仅照亮光源附近的区域而不是整个场景

随着光线传播距离的增长逐渐削减光的强度通常叫做衰减(Attenuation).

在现实世界中,灯在近处通常会非常亮,但随着距离的增加光源的亮度一开始会下降非常快,但在远处时剩余的光强度就会下降的非常缓慢了。所以,需要一个不同的公式来减少光的强度。

d代表了片段距光源的距离。接下来为了计算衰减值,定义3个(可配置的)项:常数项K~c~、一次项K~l~和二次项K~q~。

  • 常数项通常保持为1.0,它的主要作用是保证分母永远不会比1小,否则的话在某些距离上它反而会增加强度,这肯定不是我们想要的效果。
  • 一次项会与距离值相乘,以线性的方式减少强度。
  • 二次项会与距离的平方相乘,让光源以二次递减的方式减少强度。二次项在距离比较小的时候影响会比一次项小很多,但当距离值比较大的时候它就会比一次项更大了

由于二次项的存在,光线会在大部分时候以线性的方式衰退,直到距离变得足够大,当二次项超过一次项,光的强度会以更快的速度下降。这样的结果就是,光在近距离时亮度很高,但随着距离变远亮度迅速降低,最后会以更慢的速度减少亮度

具体实现是在frag中,因为已经获得了FragPos(世界坐标系下片段坐标),传入光源坐标获取距离,然后通过公式计算$F_{attn}$,将这个值作为光照的三个属性的权重

聚光

聚光是位于环境中某个位置的光源,它只朝一个特定方向而不是所有方向照射光线。这样的结果就是只有在聚光方向的特定半径内的物体才会被照亮,其它的物体都会保持黑暗聚光很好的例子就是路灯或手电筒

OpenGL中聚光是用一个世界空间位置、一个方向和一个切光角(Cutoff Angle)来表示的,切光角指定了聚光的半径.对于每个片段计算片段是否位于聚光的切光方向之间(也就是在锥形内),如果是的话就相应地照亮片段

切光角是指向方向的与两侧聚光范围的角度,所以片段在聚光范围外部就没有漫反射和镜面反射光(可以设置微弱环境光),如何判断片段是否在聚光范围内呢? 通过cos,因为两个归一化的方向向量的点乘为夹角余弦值,而在[0,180^°^]角度与余弦又呈反比. 所以当片段(fragment)与光源的向量与光源指向向量的点乘大于切光角的余弦,则片段在内部

img

  • LightDir:从片段指向光源的向量。
  • SpotDir:聚光所指向的方向。
  • Phiϕ:指定了聚光半径的切光角。落在这个角度之外的物体都不会被这个聚光所照亮。
  • Thetaθ:LightDir向量和SpotDir向量之间的夹角。在聚光内部的话θ值应该比ϕ值小。
平滑边缘

聚光范围内和外没有过渡看起来会比较锐利,类似点光源的强度衰减,这里可以模拟边缘平滑的聚光

需要模拟聚光有一个内圆锥(Inner Cone)和一个外圆锥(Outer Cone)。我们可以将内圆锥设置为上一部分中的那个圆锥,但也需要一个外圆锥,来让光从内圆锥逐渐减暗,直到外圆锥的边界。

为了创建一个外圆锥,我们只需要再定义一个余弦值来代表聚光方向向量和外圆锥向量(等于它的半径)的夹角。然后,如果一个片段处于内外圆锥之间,将会给它计算出一个0.0到1.0之间的强度值。如果片段在内圆锥之内,它的强度就是1.0,如果在外圆锥之外强度值就是0.0。

这里ϵ(Epsilon)是内(ϕ)和外圆锥(γ)之间的余弦值差(ϵ=ϕ−γ)

最终的I值就是在当前片段聚光的强度。

多光源

现实中可能存在多个不同类型的光源,当要模拟不同光源时,可以在一个片段着色器上写几个函数分别计算不同光源的颜色最后相加.

相关资料

  1. For learning opengl

  2. For another cross-platform graphics api vulkan

Vulkan needs a compiler with decent support of C++17 features(this is what modern cg api😋)

  1. For learning computer graphics:

-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道