LWJGL stands for - Lightweight Java Game Library

GL.createCapabilities();

This line is critical for LWJGL’s interoperation with GLFW’s OpenGL context, or any context that is managed externally. LWJGL detects the context that is current in the current thread, creates the GLCapabilities instance and makes the OpenGL bindings available for use.

Keyboard input

GLFW divides keyboard input into two categories; key events and character events. Key events relate to actual physical keyboard keys, whereas character events relate to the text that is generated by pressing some of them.

Keys and characters do not map 1:1. A single key press may produce several characters, and a single character may require several keys to produce. This may not be the case on your machine, but your users are likely not all using the same keyboard layout, input method or even operating system as you.

  • Key Events = which physical key was pressed → use this for game controls (WASD, ESC).
  • Character Events = which text character was generated → use this for text input (chat boxes, typing).

key events:
针对 物理键盘上的按键

character events:
在英文键盘上,按下 Shift + 2 会产生 @ 字符。在法文键盘上,按下同样的物理按键,可能会产生 éGLFW 的字符事件会告诉你 输入了 @ 或 é,而不是具体哪个物理键。

Polling

For keyboard input we can poll the key states with glfwGetKey(window, key) where window is the window handle and key is something like GLFW_KEY_<key> for example GLFW_KEY_W or GLFW_KEY_SPACE, you could also get GLFW_KEY_UNKNOWN if you push a special key like the E-mail key.

what is the word 'poll'

轮询(Polling)是一种CPU决策如何提供周边设备服务的方式,又称“程控输入输出”(Programmed I/O)。轮询法的概念是:由CPU定时发出询问,依序询问每一个周边设备是否需要其服务,有即给予服务,服务结束后再问下一个周边,接着不断周而复始。

轮询法实现容易,但效率偏低。
https://en.wikipedia.org/wiki/Polling_(computer_science)

With the call to glfwGetKey you will get the key state which is one of GLFW_PRESS and GLFW_RELEASE.For keyboard input we can poll the key states with glfwGetKey(window, key) where window is the window handle and key is something like GLFW_KEY_<key> for example GLFW_KEY_W or GLFW_KEY_SPACE, you could also get GLFW_KEY_UNKNOWN if you push a special key like the E-mail key. With the call to glfwGetKey you will get the key state which is one of GLFW_PRESS and GLFW_RELEASE.

int state = glfwGetKey(window, GLFW_KEY_UP);
if (state == GLFW_PRESS) {
    moveUp();
}

But there is a problem with polling, you could miss a pressed key if it gets released before it gets polled, but there’s a simple solution for it, you could just set sticky keys with the help of glfwSetInputMode(window, mode, value).
If we set GLFW_STICKY_KEYS to GLFW_TRUE the key state will be GLFW_PRESS until you poll that key even if it has already been released. It is useful if you only want to know if a key was pressed and it isn’t important in which order the keys where pressed.

假设我们有一个按键 W,如果在某一帧中,用户按下 W 并迅速松开,但在我们轮询之前没有调用事件处理函数,那么正常情况下我们是无法检测到 W 键的 “按下事件”,因为它已经释放了。

但是,如果我们启用了 粘滞键,那么 即使 W 键被松开了,只要它没有被轮询,它的状态仍然会显示为 GLFW_PRESS,直到你进行轮询。

// 启用 Sticky Keys
glfwSetInputMode(window, GLFW_STICKY_KEYS, GLFW_TRUE)
 
// 游戏主循环
while (!glfwWindowShouldClose(window)) {
    glfwPollEvents()
 
    // 检查按键状态
    if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS) {
        println("W key was pressed")
    }
 
    // 其他渲染逻辑
    glfwSwapBuffers(window)
}

Most of the time polling the input is sufficient, but it is recommended to use callbacks.

Callback

The key callback is invoked when a physical key is pressed or when it gets released, also when it is repeated, we can easily set a key callback via glfwSetKeyCallback(window, cbfun). Normally you would put a function pointer in there, but in Java we don’t have something like that, but luckily LWJGL provides a GLFWKeyCallback class for this. But you need a strong reference to the callback, so that it won’t get garbage collected, so just put a reference like private GLFWKeyCallback keyCallback in your class.

glfwSetKeyCallback(window, keyCallback = new GLFWKeyCallback() {
    @Override
    public void invoke(long window, int key, int scancode, int action, int mods) {
        /* Do something */
    }
}

Alternatively you could also use lambda expressions because the GLFWKeyCallback provides also a single abstract method (or simply SAM).

glfwSetKeyCallback(window, keyCallback = GLFWKeyCallback.create((window, key, scancode, action, mods) -> {
    /* Do something */
}));

Its up to you which version you want to use, but now let’s have a look at the variables. The first two should be clear by now window is the window in which the event was received and key is GLFW_KEY_<key> like before.
The scancode is the system-specific scancode of the key, but you need it only if the key is GLFW_KEY_UNKNOWN.
The state of the key is stored in action which is one of GLFW_PRESS, GLFW_RELEASE or GLFW_REPEAT and finally the mods is a bitfield(位域) of modifier keys that where pressed, it can contain GLFW_MOD_SHIFT, GLFW_MOD_CONTROL, GLFW_MOD_ALT and GLFW_MOD_SUPER.

/** The key or button was released. */  
public static final int GLFW_RELEASE = 0;  
  
/** The key or button was pressed. */  
public static final int GLFW_PRESS = 1;  
  
/** The key was held down until it repeated. */  
public static final int GLFW_REPEAT = 2;

For example you want to see if Control + Alt + F was pressed you would check it like in the following code.

/** If this bit is set one or more Control keys were held down. */  
public static final int GLFW_MOD_CONTROL = 0x2;  // 0010 二进制
  
/** If this bit is set one or more Alt keys were held down. */  
public static final int GLFW_MOD_ALT = 0x4; // 0100
 
/** If this bit is set one or more Super keys were held down. */  
public static final int GLFW_MOD_SUPER = 0x8; // 1000
 
/** If this bit is set one or more Shift keys were held down. */  
public static final int GLFW_MOD_SHIFT = 0x1; // 0001
int ctrlAlt = GLFW_MOD_ALT | GLFW_MOD_CONTROL; // ctrlAlt = 0110
/*
0100 or 0010
0100
0010
----
0110
*/
if ((mods & ctrlAlt) == ctrlAlt && key == GLFW_KEY_F && action == GLFW_PRESS) {
    System.out.println("Control + Alt + F was pressed!");
}
// mods & ctrlAlt, 假设 mods = 1111四个键全按下。 1111 & 0110 = 0110

渲染三角形

VAO and VBO

  • 顶点数组对象: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)写成的,在下一节中我们再花更多时间研究它。

下面,你会看到一个图形渲染管线的每个阶段的抽象展示。
蓝色部分代表的是我们可以注入自定义的着色器的部分。


顶点数组对象(Vertex Array Object, VAO)可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中。这样的好处就是,当配置顶点属性指针时,你只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO就行了。这使在不同顶点数据和属性配置之间切换变得非常简单,只需要绑定不同的VAO就行了。刚刚设置的所有状态都将存储在VAO中

Warning

OpenGL的核心模式要求我们使用VAO,所以它知道该如何处理我们的顶点输入。如果我们绑定VAO失败,OpenGL会拒绝绘制任何东西。

一个顶点数组对象(VAO)会储存以下这些内容:

  • glEnableVertexAttribArray 和 glDisableVertexAttribArray的调用。
  • 通过glVertexAttribPointer 设置的顶点属性配置。
  • 通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象。

Element Buffer Object

  • 元素缓冲对象:Element Buffer Object,EBO 或 索引缓冲对象 Index Buffer Object,IBO
    不用索引缓冲如何告诉OpenGL渲染

    可以看到我们发给了OpenGL 6 个 点数据 被发送了两次
    所以我们创建一个Index Buffer Object告诉OpenGL我们四个顶点的顺序

    没用IBO的情况,每个顶点占用 3 float
float vertices[] = {
     0.0f,  0.5f, 0.0f,  // 顶点 1 的 x, y, z   3 float
    -0.5f, -0.5f, 0.0f,  // 顶点 2 的 x, y, z   3 float
     0.5f, -0.5f, 0.0f   // 顶点 3 的 x, y, z   3 float
};

所以传统方式画一个矩形需要6个 Vertex 也就是 floats
加了IBO用了 4 个 Vertex floats
但是还要加上 IBO 的长度 需要 6 ints
这种情况在数据量大的时候,可以节省空间。

链接顶点属性

顶点着色器允许我们指定任何以顶点属性为形式的输入。这使其具有很强的灵活性的同时,它还的确意味着我们必须手动指定输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。所以,我们必须在渲染前指定OpenGL该如何解释顶点数据。

我们的顶点缓冲数据会被解析为下面这样子:
vertex_attribute_pointer.png|1000

  • 位置数据被储存为32位(4字节)浮点值。
  • 每个位置包含3个这样的值。
  • 在这3个值之间没有空隙(或其他值)。这几个值在数组中紧密排列(Tightly Packed)。
  • 数据中第一个值在缓冲开始的位置。
    有了这些信息我们就可以使用glVertexAttribPointer函数告诉OpenGL该如何解析顶点数据(应用到逐个顶点属性上)了:

c++ 代码实现

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);

kotlin代码实现

// 用 kotlin也可以
glVertexAttribPointer(0, 3, GL_FLOAT, false, 3 * Float.SIZE_BYTES, 0)
  • 第一个参数指定我们要配置的顶点属性。还记得我们在顶点着色器中使用layout(location = 0)定义了position顶点属性的位置值(Location)吗?它可以把顶点属性的位置值设置为0。因为我们希望把数据传递到这一个顶点属性中,所以这里我们传入0。
  • 第二个参数指定顶点属性的大小。顶点属性是一个vec3,它由3个值组成,所以大小是3。
  • 第三个参数指定数据的类型,这里是GL_FLOAT(GLSL中vec*都是由浮点数值组成的)。
  • 下个参数定义我们是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为GL_FALSE。
  • 第五个参数叫做步长(Stride),它告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在3个float之后,我们把步长设置为3 * sizeof(float)。要注意的是由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。一旦我们有更多的顶点属性,我们就必须更小心地定义每个顶点属性之间的间隔,我们在后面会看到更多的例子(译注: 这个参数的意思简单说就是从这个属性第二次出现的地方到整个数组0位置之间有多少字节)。
  • 最后一个参数的类型是void*,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的偏移量(Offset)。由于位置数据在数组的开头,所以这里是0。我们会在后面详细解释这个参数。

每个顶点属性从一个VBO管理的内存中获得它的数据,而具体是从哪个VBO(程序中可以有多个VBO)获取则是通过在调用glVertexAttribPointer时绑定到GL_ARRAY_BUFFER的VBO决定的。由于在调用glVertexAttribPointer之前绑定的是先前定义的VBO对象,顶点属性0现在会链接到它的顶点数据。

现在我们已经定义了OpenGL该如何解释顶点数据,我们现在应该使用glEnableVertexAttribArray,以顶点属性位置值作为参数,启用顶点属性;顶点属性默认是禁用的。自此,所有东西都已经设置好了:我们使用一个顶点缓冲对象将顶点数据初始化至缓冲中,建立了一个顶点和一个片段着色器,并告诉了OpenGL如何把顶点数据链接到顶点着色器的顶点属性上。在OpenGL中绘制一个物体,代码会像是这样:

val vertices = floatArrayOf( // 三角形三个顶点
		-0.5f, -0.5f, 0.0f, // 左下
		0.5f, -0.5f, 0.0f,  // 右下
		0.0f, 0.5f, 0.0f    // 上
)
 
var VBO: Int = GL30.glGenBuffers()
var VAO: Int = GL30.glGenVertexArrays()
 
glBindBuffer(GL_ARRAY_BUFFER, VBO) // 把新创建的缓冲绑定到GL_ARRAY_BUFFER目标上
// 从这一刻起,我们使用的任何(在GL_ARRAY_BUFFER目标上的)缓冲调用都会用来配置当前绑定的缓冲(VBO)。然后我们可以调用glBufferData函数,它会把之前定义的顶点数据复制到缓冲的内存中:
glBufferData(GL_ARRAY_BUFFER, vertices, GL_STATIC_DRAW)
glVertexAttribPointer(0, 3, GL_FLOAT, false, 3 * Float.SIZE_BYTES, 0)
glEnableVertexAttribArray(0);
 
// 2. 使用着色器程序 ... 
 
// 3. 绘制物体 ........ someOpenGLFunctionThatDrawsOurTriangle();

每当我们绘制一个物体的时候都必须重复这一过程。这看起来可能不多,但是如果有超过5个顶点属性,上百个不同物体呢(这其实并不罕见)。绑定正确的缓冲对象,为每个物体配置所有顶点属性很快就变成一件麻烦事。有没有一些方法可以使我们把所有这些状态配置储存在一个对象中,并且可以通过绑定这个对象来恢复状态呢?
解答:通过 VAO


对于这一些顶点属性

float vertices[] = {
// positions         // colors
0.5f, -0.5f, 0.0f,  1.0f, 0.0f, 0.0f,  // bottom right
-0.5f, -0.5f, 0.0f,  0.0f, 1.0f, 0.0f,  // bottom left
0.0f,  0.5f, 0.0f,  0.0f, 0.0f, 1.0f   // top 
};

memoryLayout|1000

// 位置属性
glVertexAttribPointer(0, 3, GL_FLOAT, false, 6 * Float.SIZE_BYTES, 0L)
glEnableVertexAttribArray(0)
// 颜色属性
glVertexAttribPointer(1, 3, GL_FLOAT, false, 6 * Float.SIZE_BYTES, (3 * Float.SIZE_BYTES).toLong())
glEnableVertexAttribArray(1)

glVertexAttribPointer函数的前几个参数比较明了。这次我们配置属性位置值为1的顶点属性。颜色值有3个float那么大,我们不去标准化这些值。

由于我们现在有了两个顶点属性,我们不得不重新计算步长值。为获得数据队列中下一个属性值(比如位置向量的下个x分量)我们必须向右移动6个float,其中3个是位置值,另外3个是颜色值。这使我们的步长值为6乘以float的字节数(=24字节)。
同样,这次我们必须指定一个偏移量。对于每个顶点来说,位置顶点属性在前,所以它的偏移量是0。颜色属性紧随位置数据之后,所以偏移量就是3 * sizeof(float),用字节来计算就是12字节。


val vertices = floatArrayOf( // 三角形三个顶点  
    // position  
    -0.5f, -0.5f, 0.0f, // 左下  
    0.5f, -0.5f, 0.0f,  // 右下  
    0.0f, 0.5f, 0.0f,    // 上  
    // color  
    0.0f, 1.0f, 0.0f, // 左下  
    0.0f, 0.0f, 1.0f, // 右下  
    1.0f, 0.0f, 0.0f // 顶部  
)
// ......................
// 位置属性
glVertexAttribPointer(0, 3, GL_FLOAT, false, 3 * Float.SIZE_BYTES, 0L)
glEnableVertexAttribArray(0)
// 颜色属性
glVertexAttribPointer(1, 3, GL_FLOAT, false, 3 * Float.SIZE_BYTES, (3 * 3 * Float.SIZE_BYTES).toLong() )
glEnableVertexAttribArray(1)

代码执行流程

shader

GLSL

着色器是使用一种叫GLSL的类C语言写成的。GLSL是为图形计算量身定制的,它包含一些针对向量和矩阵操作的有用特性。

着色器的开头总是要声明版本,接着是输入和输出变量、uniform和main函数。每个着色器的入口点都是main函数,在这个函数中我们处理所有的输入变量,并将结果输出到输出变量中。如果你不知道什么是uniform也不用担心,我们后面会进行讲解。
一个典型的着色器有下面的结构:

#version version_number
in type in_variable_name;
in type in_variable_name;
 
out type out_variable_name;
 
uniform type uniform_name;
 
void main()
{
  // 处理输入并进行一些图形操作
  ...
  // 输出处理过的结果到输出变量
  out_variable_name = weird_stuff_we_processed;
}

当我们特别谈论到顶点着色器的时候,每个输入变量也叫顶点属性(Vertex Attribute)。我们能声明的顶点属性是有上限的,它一般由硬件来决定。OpenGL确保至少有16个包含4分量的顶点属性可用,但是有些硬件或许允许更多的顶点属性,你可以查询GL_MAX_VERTEX_ATTRIBS来获取具体的上限:

int nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);
std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl;

数据类型

和其他编程语言一样,GLSL有数据类型可以来指定变量的种类。GLSL中包含C等其它语言大部分的默认基础数据类型:int、float、double、uint和bool。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访问相同的分量。

向量这一数据类型也允许一些有趣而灵活的分量选择方式,叫做重组(Swizzling)。重组允许这样的语法:

vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;

你可以使用上面4个字母任意组合来创建一个和原来向量一样长的(同类型)新向量,只要原来向量有那些分量即可;然而,你不允许在一个vec2向量中去获取.z元素。我们也可以把一个向量作为一个参数传给不同的向量构造函数,以减少需求参数的数量:

vec2 vect = vec2(0.5, 0.7);
vec4 result = vec4(vect, 0.0, 0.0);
vec4 otherResult = vec4(result.xyz, 1.0);

Example

// Vertex Shader
#version 330 core  
/*  
顶点着色器的输入特殊在,它从顶点数据中直接接收输入。  
为了定义顶点数据该如何管理,我们使用location这一元数据指定输入变量,这样我们才可以在CPU上配置顶点属性。  
我们已经在前面的教程看过这个了,layout (location = 0)。顶点着色器需要为它的输入提供一个额外的layout标识,这样我们才能把它链接到顶点数据。  
*/  
layout (location = 0) in vec3 aPos; // // 位置变量的属性位置值为0  
  
// vec3 输入变量 aPos// layout (location = 0)设定了输入变量的位置值(Location)  
  
void main()  
{  
    // 输出:              x        y      z      w  
    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);  
}
// Fragment Shader
#version 330 core  
out vec4 FragColor;  
  
void main()  
{  
    FragColor = vec4(1.0f, 0.5f, 1.0f, 1.0f);  // 紫色
}

// Vertex Shader
#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置值为0
 
out vec4 vertexColor; // 为片段着色器指定一个颜色输出
 
void main()
{
    gl_Position = vec4(aPos, 1.0); // 注意我们如何把一个vec3作为vec4的构造器的参数
    vertexColor = vec4(0.5, 0.0, 0.0, 1.0); // 把输出变量设置为暗红色
}
// Fragment Shader
#version 330 core
out vec4 FragColor;
 
in vec4 vertexColor; // 从顶点着色器传来的输入变量(名称相同、类型相同)
 
void main()
{
    FragColor = vertexColor;
}

Texture Coordinate

纹理坐标在x和y轴上,范围为0到1之间(注意我们使用的是2D纹理图像)。使用纹理坐标获取纹理颜色叫做采样(Sampling)。纹理坐标起始于(0, 0),也就是纹理图片的左下角,终止于(1, 1),即纹理图片的右上角。下面的图片展示了我们是如何把纹理坐标映射到三角形上的。

我们为三角形指定了3个纹理坐标点。如上图所示,我们希望三角形的左下角对应纹理的左下角,因此我们把三角形左下角顶点的纹理坐标设置为(0, 0);同理右下方的顶点设置为(1, 0);三角形的上顶点对应于图片的上中位置所以我们把它的纹理坐标设置为(0.5, 1.0)。我们只要给顶点着色器传递这三个纹理坐标就行了,接下来它们会被传到片段着色器中,它会为每个片段进行纹理坐标的插值。
纹理坐标看起来就像这样:

float texCoords[] = {
    0.0f, 0.0f, // 左下角
    1.0f, 0.0f, // 右下角
    0.5f, 1.0f  // 上中
};

对纹理采样的解释非常宽松,它可以采用几种不同的插值方式。所以我们需要自己告诉OpenGL该怎样对纹理采样。

纹理环绕方式

纹理坐标的范围通常是从(0, 0)到(1, 1),那如果我们把纹理坐标设置在范围之外会发生什么?OpenGL默认的行为是重复这个纹理图像(我们基本上忽略浮点纹理坐标的整数部分),但OpenGL提供了更多的选择:

环绕方式描述
GL_REPEAT对纹理的默认行为。重复纹理图像。
GL_MIRRORED_REPEAT和GL_REPEAT一样,但每次重复图片是镜像放置的。
GL_CLAMP_TO_EDGE纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。
GL_CLAMP_TO_BORDER超出的坐标为用户指定的边缘颜色。
当纹理坐标超出默认范围时,每个选项都有不同的视觉效果输出。(具体看官方文档)
前面提到的每个选项都可以使用glTexParameter*
函数对单独的一个坐标轴设置(s、t(如果是使用3D纹理那么还有一个r)它们和x、y、z是等价的):
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);

第一个参数指定了纹理目标;我们使用的是2D纹理,因此纹理目标是GL_TEXTURE_2D。第二个参数需要我们指定设置的选项与应用的纹理轴。我们打算配置的是WRAP选项,并且指定S和T轴。最后一个参数需要我们传递一个环绕方式(Wrapping),在这个例子中OpenGL会给当前激活的纹理设定纹理环绕方式为GL_MIRRORED_REPEAT。

如果我们选择GL_CLAMP_TO_BORDER选项,我们还需要指定一个边缘的颜色。这需要使用glTexParameter函数的fv后缀形式,用GL_TEXTURE_BORDER_COLOR作为它的选项,并且传递一个float数组作为边缘的颜色值:

float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

Uniform Variables

Uniform variables are variables in the Shader code that can be set from our Java code at any time.
Uniform是另一种从我们的应用程序在 CPU 上传递数据到 GPU 上的着色器的方式,但uniform和顶点属性有些不同。首先,uniform是全局的(Global)。全局意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。第二,无论你把uniform值设置成什么,uniform会一直保存它们的数据,直到它们被重置或更新。

要在 GLSL 中声明 uniform,我们只需在着色器中使用 uniform 关键字,并带上类型和名称。从那时起,我们就可以在着色器中使用新声明的 uniform。我们来看看这次是否能通过uniform设置三角形的颜色:

#version 330 core
out vec4 FragColor;
 
uniform vec4 ourColor; // 在OpenGL程序代码中设定这个变量
 
void main()
{
    FragColor = ourColor;
}

我们在片段着色器中声明了一个uniform vec4的ourColor,并把片段着色器的输出颜色设置为uniform值的内容。因为uniform是全局变量,我们可以在任何着色器中定义它们,而无需通过顶点着色器作为中介。顶点着色器中不需要这个uniform,所以我们不用在那里定义它。

Warning

如果你声明了一个uniform却在GLSL代码中没用过,编译器会静默移除这个变量,导致最后编译出的版本中并不会包含它,这可能导致几个非常麻烦的错误,记住这点!

这个uniform现在还是空的;我们还没有给它添加任何数据,所以下面我们就做这件事。我们首先需要找到着色器中uniform属性的索引/位置值。当我们得到uniform的索引/位置值后,我们就可以更新它的值了。这次我们不去给像素传递单独一个颜色,而是让它随着时间改变颜色:

val timeValue = glfwGetTime()
 
val redValue   = (sin(timeValue * 0.8) * 0.5 + 0.5).toFloat()
val greenValue = (sin(timeValue * 0.6 + 2.0) * 0.5 + 0.5).toFloat()
val blueValue  = (sin(timeValue * 1.0 + 4.0) * 0.5 + 0.5).toFloat()
 
val vertexColorLocation = glGetUniformLocation(ShaderProgram.getProgramID, "aColor")
glUniform3f(vertexColorLocation, redValue, greenValue, blueValue, )

glUniform+{1|2|3|4}+{f|i|ui}代表设置 Uniform 变量的类型

uniform vec3 aColor; → glUniform3f
uniform vec4 ourColor; → glUniform4f
uniform float time; → glUniform1f
uniform int var1; → glUniform1i

变换

Translation

位移(Translation)是在原始向量的基础上加上另一个向量从而获得一个在不同位置的新向量的过程,从而在位移向量基础上移动了原始向量。我们已经讨论了向量加法,所以这应该不会太陌生。

和缩放矩阵一样,在4×4矩阵上有几个特别的位置用来执行特定的操作,对于位移来说它们是第四列最上面的3个值。如果我们把位移向量表示为,我们就能把位移矩阵定义为:

逆矩阵

设A是n阶方阵,若存在n阶方阵B,使

E为单位矩阵
则称A是可逆矩阵,B为A的逆矩阵,记作,即

JOML (Java OpenGL Math Library)

// 定义一个四维向量
val vec = Vector4f(1.0f, 0.0f, 0.0f, 1.0f)
val trans = Matrix4f() // 创建一个矩阵,默认为单位矩阵
// 等同于
//val trans = Matrix4f().identity()

// 平移->一个向量的单位 (修改变换矩阵本身)
trans.translate(Vector3f(1.0f, 1.0f, 0.0f))

// 对之前定义的向量vec进行平移操作
val result = Vector4f() // 结果向量
trans.transform(vec, result)
println(result)// 结果向量

坐标系统

在上一个教程中,我们学习了如何有效地利用矩阵的变换来对所有顶点进行变换。OpenGL希望在每次顶点着色器运行后,我们可见的所有顶点都为标准化设备坐标(Normalized Device Coordinate, NDC)。也就是说,每个顶点的x,y,z坐标都应该在 -1.0 到 1.0 之间,超出这个坐标范围的顶点都将不可见。我们通常会自己设定一个坐标的范围,之后再在顶点着色器中将这些坐标变换为标准化设备坐标。然后将这些标准化设备坐标传入光栅器(Rasterizer),将它们变换为屏幕上的二维坐标或像素。

将坐标变换为标准化设备坐标,接着再转化为屏幕坐标的过程通常是分步进行的,也就是类似于流水线那样子。在流水线中,物体的顶点在最终转化为屏幕坐标之前还会被变换到多个坐标系统(Coordinate System)。将物体的坐标变换到几个过渡坐标系(Intermediate Coordinate System)的优点在于,在这些特定的坐标系统中,一些操作或运算更加方便和容易,这一点很快就会变得很明显。对我们来说比较重要的总共有5个不同的坐标系统:

  • 局部空间(Local Space,或者称为物体空间(Object Space))
  • 世界空间(World Space)
  • 观察空间(View Space,或者称为视觉空间(Eye Space))
  • 裁剪空间(Clip Space)
  • 屏幕空间(Screen Space)

概述

为了将坐标从一个坐标系变换到另一个坐标系,我们需要用到几个变换矩阵,最重要的几个分别是模型(Model)、观察(View)、投影(Projection)三个矩阵。我们的顶点坐标起始于局部空间(Local Space),在这里它称为局部坐标(Local Coordinate),它在之后会变为世界坐标(World Coordinate),观察坐标(View Coordinate),裁剪坐标(Clip Coordinate),并最后以屏幕坐标(Screen Coordinate)的形式结束。下面的这张图展示了整个流程以及各个变换过程做了什么:
matrix|1200

  1. 局部坐标是对象相对于局部原点的坐标,也是物体起始的坐标。
  2. 下一步是将局部坐标变换为世界空间坐标,世界空间坐标是处于一个更大的空间范围的。这些坐标相对于世界的全局原点,它们会和其它物体一起相对于世界的原点进行摆放。
  3. 接下来我们将世界坐标变换为观察空间坐标,使得每个坐标都是从摄像机或者说观察者的角度进行观察的。
  4. 坐标到达观察空间之后,我们需要将其投影到裁剪坐标。裁剪坐标会被处理至-1.0到1.0的范围内,并判断哪些顶点将会出现在屏幕上。
  5. 最后,我们将裁剪坐标变换为屏幕坐标,我们将使用一个叫做视口变换(Viewport Transform)的过程。视口变换将位于-1.0到1.0范围的坐标变换到由glViewport函数所定义的坐标范围内。最后变换出来的坐标将会送到光栅器,将其转化为片段。

局部空间

局部空间是指物体所在的坐标空间,即对象最开始所在的地方。想象你在一个建模软件(比如说Blender)中创建了一个立方体。你创建的立方体的原点有可能位于(0, 0, 0),即便它有可能最后在程序中处于完全不同的位置。甚至有可能你创建的所有模型都以(0, 0, 0)为初始位置(译注:然而它们会最终出现在世界的不同位置)。所以,你的模型的所有顶点都是在局部空间中:它们相对于你的物体来说都是局部的。

我们一直使用的那个箱子的顶点是被设定在-0.5到0.5的坐标范围中,(0, 0)是它的原点。这些都是局部坐标。


可以简单理解为blender中的模型坐标。

世界空间

如果我们将我们所有的物体导入到程序当中,它们有可能会全挤在世界的原点(0, 0, 0)上,这并不是我们想要的结果。我们想为每一个物体定义一个位置,从而能在更大的世界当中放置它们。世界空间中的坐标正如其名:是指顶点相对于(游戏)世界的坐标。如果你希望将物体分散在世界上摆放(特别是非常真实的那样),这就是你希望物体变换到的空间。物体的坐标将会从局部变换到世界空间;该变换是由模型矩阵(Model Matrix)实现的。

模型矩阵是一种变换矩阵,它能通过对物体进行位移、缩放、旋转来将它置于它本应该在的位置或朝向。你可以将它想像为变换一个房子,你需要先将它缩小(它在局部空间中太大了),并将其位移至郊区的一个小镇,然后在y轴上往左旋转一点以搭配附近的房子。你也可以把上一节将箱子到处摆放在场景中用的那个矩阵大致看作一个模型矩阵;我们将箱子的局部坐标变换到场景/世界中的不同位置。


简单理解为 minecraft 中的 world 坐标

观察空间

观察空间经常被人们称之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)。所有在范围外的坐标不会被映射到在-1.0到1.0的范围之间,所以会被裁剪掉。在上面这个投影矩阵所指定的范围内,坐标(1250, 500, 750)将是不可见的,这是由于它的x坐标超出了范围,它被转化为一个大于1.0的标准化设备坐标,所以被裁剪掉了。

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

正射投影

正射投影有时也称为正交投影。
通俗:垂直(正交)向幕布进行投影,称为正交投影;更直观展现物体在某投影平面的位置关系。但是,无法看到物体之间的近大远小的关系。

透视投影

通俗:假设场景中有一个摄像头,场景当中所有物体都汇聚到摄像头的镜头点上,从而呈现出图像,称为透视投影。可以看到物体之间的近大远小的关系。

如果你曾经体验过实际生活给你带来的景象,你就会注意到离你越远的东西看起来更小。这个奇怪的效果称之为透视(Perspective)。透视的效果在我们看一条无限长的高速公路或铁路时尤其明显,正如下面图片显示的那样:

正如你看到的那样,由于透视,这两条线在很远的地方看起来会相交。这正是透视投影想要模仿的效果,它是使用透视投影矩阵来完成的。这个投影矩阵将给定的平截头体范围映射到裁剪空间,除此之外还修改了每个顶点坐标的w值,从而使得离观察者越远的顶点坐标w分量越大。被变换到裁剪空间的坐标都会在-w到w的范围之间(任何大于这个范围的坐标都会被裁剪掉)。OpenGL要求所有可见的坐标都落在-1.0到1.0范围内,作为顶点着色器最后的输出,因此,一旦坐标在裁剪空间内之后,透视除法就会被应用到裁剪空间坐标上:

顶点坐标的每个分量都会除以它的w分量,距离观察者越远顶点坐标就会越小。这是w分量重要的另一个原因,它能够帮助我们进行透视投影。最后的结果坐标就是处于标准化设备空间中的。如果你对正射投影矩阵和透视投影矩阵是如何计算的很感兴趣(且不会对数学感到恐惧的话)我推荐这篇由Songho写的文章。
在GLM中可以这样创建一个透视投影矩阵:

glm::mat4 proj = glm::perspective(glm::radians(45.0f), (float)width/(float)height, 0.1f, 100.0f);

同样,glm::perspective所做的其实就是创建了一个定义了可视空间的大平截头体,任何在这个平截头体以外的东西最后都不会出现在裁剪空间体积内,并且将会受到裁剪。一个透视平截头体可以被看作一个不均匀形状的箱子,在这个箱子内部的每个坐标都会被映射到裁剪空间上的一个点。下面是一张透视平截头体的图片:
projection|1200

它的第一个参数定义了fov的值,它表示的是视野(Field of View),并且设置了观察空间的大小。如果想要一个真实的观察效果,它的值通常设置为45.0f,但想要一个毁灭战士(DOOM,经典的系列第一人称射c击游戏)风格的结果你可以将其设置一个更大的值。第二个参数设置了宽高比,由视口的宽除以高所得。第三和第四个参数设置了平截头体的近(near)和远(far)平面。我们通常设置近距离为0.1f,而远距离设为100.0f。所有在近平面和远平面内且处于平截头体内的顶点都会被渲染。

near跟far都是正数

当你把透视矩阵的 near 值设置太大时(如10.0f),OpenGL会将靠近摄像机的坐标(在0.0f和10.0f之间)都裁剪掉,这会导致一个你在游戏中很熟悉的视觉效果:在太过靠近一个物体的时候你的视线会直接穿过去。

摄像机

前面的教程中我们讨论了观察矩阵以及如何使用观察矩阵移动场景(我们向后移动了一点)。OpenGL本身没有摄像机(Camera)的概念,但我们可以通过把场景中的所有物体往相反方向移动的方式来模拟出摄像机,产生一种我们在移动的感觉,而不是场景在移动。

摄像机/观察空间

当我们讨论摄像机/观察空间(Camera/View Space)的时候,是在讨论以摄像机的视角作为场景原点时场景中所有的顶点坐标:观察矩阵把所有的世界坐标变换为相对于摄像机位置与方向的观察坐标。要定义一个摄像机,我们需要它在世界空间中的位置、观察的方向、一个指向它右侧的向量以及一个指向它上方的向量(穹顶)。
camera|1200
最简单的情况

摄像机位置摆放在世界坐标系原点
摄像机看向的方向是负Z方向
穹顶向量为y轴方向
在这种情况下计算投影非常简单。

如果把摄像机平移到别到地方,计算投影会变复杂

解决:将物体的顶点坐标由世界坐标系转化到摄像机坐标系中(通过 View Matrix 来实现)世界系 → 相机系

P摄像机坐标系的坐标位置求出后,就可以顺利进行简单投影了
举个例子:

设点P在世界坐标系的坐标为(cx,cy,cz),相机位置为原点
摄像机经历变换M,并且P点跟随其共同变换

  • P点与摄像机保持相对静止
  • P点摄像机坐标系下坐标仍然是(cx,cy,cz)
  • 由于P点经历了摄像机相同变换矩阵M,则其世界坐标为:
    所以已知某点的摄像机坐标系坐标与相机变换矩阵M,求世界坐标系坐标:
    移项可知摄像机坐标系坐标的公式

推导过程:

视角移动

var cameraPos = Vector3f(0.0f, 0.0f, 3.0f)

Drawing 2025-09-10 15.37.15.excalidraw

⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠ You can decompress Drawing data with the command palette: ‘Decompress current Excalidraw file’. For more info check in plugin settings under ‘Saving’

Excalidraw Data

Text Elements

(0,0,3)

Link to original

var cameraFront = Vector3f(0.0f, 0.0f, -1.0f)

Drawing 2025-09-10 15.37.15.excalidraw 1

⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠ You can decompress Drawing data with the command palette: ‘Decompress current Excalidraw file’. For more info check in plugin settings under ‘Saving’

Excalidraw Data

Text Elements

(0,0,3)

cameraFront
(0,0,-1)

Link to original

private val worldUp = Vector3f(0.0f, 1.0f, 0.0f)

Drawing 2025-09-10 15.37.15.excalidraw 2

⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠ You can decompress Drawing data with the command palette: ‘Decompress current Excalidraw file’. For more info check in plugin settings under ‘Saving’

Excalidraw Data

Text Elements

(0,0,3)

cameraFront
(0,0,-1)

(0,1,0)

Link to original

val cameraRight: Vector3f
        get() = Vector3f(cameraFront).cross(worldUp).normalize()

Drawing 2025-09-10 15.37.15.excalidraw 3

⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠ You can decompress Drawing data with the command palette: ‘Decompress current Excalidraw file’. For more info check in plugin settings under ‘Saving’

Excalidraw Data

Text Elements

(0,0,3)

cameraFront
(0,0,-1)

(0,1,0)

cameraRight

Link to original

val cameraUp: Vector3f
        get() = Vector3f(cameraRight).cross(cameraFront).normalize()

Drawing 2025-09-10 15.37.15.excalidraw 4

⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠ You can decompress Drawing data with the command palette: ‘Decompress current Excalidraw file’. For more info check in plugin settings under ‘Saving’

Excalidraw Data

Text Elements

(0,0,3)

cameraFront
(0,0,-1)

(0,1,0)

cameraRight

cameraUp

worldUp

Link to original

fun createViewMatrix(): Matrix4f {
	// 使用 cameraPos 和 cameraPos + cameraFront 生成 LookAt(避免使用固定 target)
	val center = Vector3f(cameraPos).add(cameraFront)
	return Matrix4f().lookAt(cameraPos, center, cameraUp)
}
// 实时计算 摄像机注视目标的向量

lookAt()函数:

  • 第一个参数为:摄像机位置
  • 第二个参数:摄像机注视目标的向量
  • 第三个参数:摄像机的法向量
// 加上键盘录入
if(KeyListener.isKeyPressed(GLFW_KEY_W)){
	camera.cameraPos.add(Vector3f(camera.cameraFront).normalize().mul(speed))
}
if(KeyListener.isKeyPressed(GLFW_KEY_S)){
	camera.cameraPos.sub(Vector3f(camera.cameraFront).mul(speed))
}
if(KeyListener.isKeyPressed(GLFW_KEY_A)){
	camera.cameraPos.sub(Vector3f(camera.cameraRight).normalize().mul(speed))
}
if(KeyListener.isKeyPressed(GLFW_KEY_D)){
	camera.cameraPos.add(Vector3f(camera.cameraRight).normalize().mul(speed))
}