目录

  • 数据类型和函数名前后缀含义
  • 缓冲区和顶点属性
  • 统一变量
  • 顶点属性插值(光栅着色器)
  • 模型-视图和透视矩阵
  • 第一个3D程序——一个3D立方体
  • 打印[模型-视图矩阵]对象
  • 分析:物体经模型-视图矩阵变换后的坐标值是[相机空间]的坐标值
  • 为什么取Z~near~=0.1,Z~far~=1000,FOV角=60°呢?
  • 近剪裁平面那么小,有什么不对吗?
  • 改变物体尺寸及视口尺寸对物体呈现的影响
  • 初始窗口尺寸对之后物体呈现的影响
  • 解决改变窗口尺寸时,优化显示区域(glViewport())
  • 生成有插值颜色的立方体
  • 旋转动画
  • cameraZ的取值对视野的影响
  • 渲染一个对象的多个副本
  • 实例化
  • 在同一个场景中渲染多个不同模型
  • glDrawArrays()是如何识别用哪个缓冲区数据绘图的?
  • OpenGL状态机
  • 矩阵堆栈
  • 问题1:glm::rotate()模型是自转还是绕[世界坐标系]的轴旋转?
  • 问题2:如何实现模型的自转?
  • 应对“Z冲突”伪影
  • 图元的其他选项
  • 性能优先的编程方法&背面剔除
  • 补充说明
数据类型和函数名前后缀含义

OpenGL的数据类型定义可以与其它语言一致,但建议在ANSI C下最好使用以下定义的数据类型:

前缀 数据类型 相应C语言类型 OpenGL类型
b 8-bit integer signed char GLbyte
s 16-bit integer short GLshort
i 32-bit integer long GLint, GLsizei
f 32-bit floating-point float GLfloat, GLclampf
d 64-bit floating-point double GLdouble, GLclampd
ub 8-bit unsigned integer unsigned char GLubyte, GLboolean
us 16-bit unsigned integer unsigned short GLushort
ui 32-bit unsigned integer unsigned long GLuint, GLenum, GLbitfield

函数名有前缀,然后头字母大写,后缀是参数类型的简写,例如:

glVertex2i(2, 4);
glVertex3f(2.0, 4.0, 5.0);
glColor3f(1.0, 0.0, 0.0);
float colorArr[] = {1.0, 0.0, 0.0};
glColor3fv(colorArr);

可以看到函数名,是以"gl"为前缀的,因为调用的是OpenGL标准库函数,如果函数分属于其他类库,前缀就会不一样,可能为"wgl"、“glx"等。中间"Vertex”、“Color"表示函数的基本含义。后缀前带有数字2、3、4,其中2代表二维、3代表三维,4代表alpha值。后缀带字母i、f等,表示参数数据类型。
有的后缀带一个字母"v”,表示函数参数可用于一个指针指向一个向量(或数组)来替代一系列单个参数值。以上代码中glColor3f与glColor3fv都代表红色,二者等价。
还有一种后缀前带“*”星号的表示方法,例如glVertex*v()表示用一个指针指向所有类型的向量来定义一系列顶点坐标值。

举个函数定义的例子:glUniformMatrix(2|3|4|2×3|3×2|2×4|4×2|3×4|4×3)fv(GLint, GLsizei, GLboolean, const GLfloat*)
数字2表示一个2×2矩阵,数字3表示一个3×3矩阵,数字4表示一个4×4矩阵;2×4表示一个2×4矩阵(即2列4行)(注:先列后行)。后缀中“f”代表是浮点数据类型,“v”代表函数参数可以是一个指向一个向量(vector)(或数组array)的指针来替代一系列单个参数值的集合。

详细还可参见:GLSL基础语法介绍
———————————————————————————————

使用 OpenGL 渲染 3D 图形通常需要将若干数据集发送给 OpenGL 着色器管线。

想要绘制一个简单的 3D 对象,比如一个立方体,你至少需要发送以下项目:

  • 立方体模型的顶点
  • 控制立方体在3D空间中朝向表现的变换矩阵

把数据发送给OpenGL管线还要更加复杂一点,有两种方式:

  1. 通过顶点属性的缓冲区
  2. 直接发送给统一变量
缓冲区和顶点属性

想要绘制一个对象,它的顶点数据需要被【发送】给[ 顶点着色器 ]。通常会把顶点数据在 C++端放入一个缓冲区,并把这个缓冲区和着色器中声明的顶点属性相关联。
要完成这件事,有好几个步骤,有些步骤只需要做一次,而如果是动画场景的话,有些步骤需要每帧都做一次:

只做一次的步骤——一般是在init()中:
(1) 创建一个缓冲区
(2) 将顶点数据复制到缓冲区

每帧都要做的步骤——一般是在display()中:
(1) 启用包含了顶点数据的缓冲区(标记为“活跃”)
(2) 将这个缓冲区和一个顶点属性相关联
(3) 启用这个顶点属性
(4) 使用glDrawArrays()绘制对象

在OpenGL中,缓冲区被包含在顶点缓冲对象(Vertex Buffer Object, VBO)中。当glDrawArrays()被执行时,缓冲区中的数据开始流动,从缓冲区的开头开始,按顺序流过[ 顶点着色器 ],顶点着色器对每个顶点执行一次。

3D空间中的顶点需要3个数值,所以着色器中的顶点属性常常会以vec3类型接收到这3个数值。然后,对缓冲区中的每组这3个数值,着色器会被调用。

OpenGL 3.0版本还引入了一种相关的结构,叫顶点数组对象(Vertex Array Object, VAO)。VAO作为一种组织缓冲区的方法,让缓冲区在复杂场景中更容易操控。

OpenGL要求至少创建一个VAO。

假设我们要显示两个对象,在C++端,我们可以声明一个VAO和两个相关的VBO(每个对象一个):

GLuint vao[1];// OpenGL要求这些数值以数组的形式指定
GLuint vbo[2];// 两个对象对应两个VBO
...
glGenVertexArrays(1, vao);// 创建VAO,返回整数ID存进vao[0]
glBindVertexArray(vao[0]);// 绑定VAO,将第0个VAO标记为“活跃”
glGenBuffers(2, vbo);// 创建两个VBO,返回两个整数ID分别存进vbo[0]、vbo[1]glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);// 将第1个VBO标记为“活跃”
glBufferData(GL_ARRAY_BUFFER, sizeof(vertexArr), vertexArr, GL_STATIC_DRAW);// 将顶点数组数据复制进活跃缓冲区(此处为第1个VBO)

标记为“活跃”的意思是:将生成的缓冲区和这个VAO / VBO相关联。

为什么用“缓冲区”?目的就是加快运算速度,直接在内存中存取肯定比从硬盘存取速度快得多,而OpenGL对算力是有很高甚至是极致要求的。

void glGenBuffers(GLsizei n, GLuint* buffers)
n - 指定要生成的缓冲区对象名称的数目。
buffers - 指定存储生成的缓冲区对象名称的数组。
在buffer中返回n个buffer对象名称。

void glBindBuffer(GLenum target, GLuint buffer)
target - 指定buffer对象绑定的目标。(该目标必须是下表中的buffer绑定目标之一)
buffer - 指定缓冲区对象的名称。

buffer绑定目标 用途
GL_ARRAY_BUFFER 顶点属性
GL_ATOMIC_COUNTER_BUFFER 原子计数器
GL_COPY_READ_BUFFER 复制源
GL_COPY_WRITE_BUFFER 复制目的地
GL_DISPATCH_INDIRECT_BUFFER 间接计算调度命令
GL_DRAW_INDIRECT_BUFFER 间接命令参数
GL_ELEMENT_ARRAY_BUFFER 顶点数组索引
GL_PIXEL_PACK_BUFFER 像素读取目标
GL_PIXEL_UNPACK_BUFFER 纹理数据来源
GL_QUERY_BUFFER 查询结果
GL_SHADER_STORAGE_BUFFER 着色器读写
GL_TEXTURE_BUFFER 纹理数据
GL_TRANSFORM_FEEDBACK_BUFFER 转换反馈
GL_UNIFORM_BUFFER 统一块
glBindBuffer将一个缓冲区对象绑定到指定的缓冲区绑定点。调用glBindBuffer时,将target设置为一个可接受的符号常量,并将buffer设置为一个缓冲区对象的名称,从而将该缓冲区对象的名称绑定到目标。
如果不存在名称为buffer的buffer对象,则创建一个以该名称命名的buffer对象。当一个缓冲区对象绑定到一个目标时,该目标的前一个绑定将自动中断。

缓冲区对象名称是无符号整数。0值是保留的,但是每个缓冲区对象目标没有缺省的缓冲区对象。相反,将buffer设置为0可以有效地解除之前绑定的任何buffer对象,并恢复该buffer对象目标的客户端内存使用(如果该目标支持的话)。
缓冲区对象名称和相应的缓冲区对象内容对于当前GL渲染上下文(rendering context)的共享对象空间(shared object space)是局部的(local);只有当两个渲染上下文通过适当的GL windows接口函数显式地启用上下文之间的共享时,它们才共享缓冲区对象名称。
必须使用glGenBuffers生成一组未使用的缓冲区对象名称。
缓冲区对象第一次绑定后的状态是一个未映射的零大小内存缓冲区,具有GL_READ_WRITE访问(access)权限和GL_STATIC_DRAW使用(usage)权限。

当非零缓冲区对象绑定到GL_ARRAY_BUFFER目标时,顶点数组指针参数将被解释为缓冲区对象内的偏移量(单位:基本机器单元)。

void glBufferData(GLenum target, GLsizeiptr size, const void * data, GLenum usage);
size:类型是GLsizeiptr,表示非负二进制整数,用于指针偏移量和范围取值(例子中用此类型承载sizeof返回的字节数)。相似的还有GLintptr类型,表示有符号二补数二进制整数。
target:GL_ARRAY_BUFFER,即数组缓冲区,用于存储颜色、位置、纹理坐标等顶点属性,或者其他自定义属性。(还有许多其他target取值。)
usage:GL_STATIC_DRAW:"Static”意味着VBO中的数据不会被改变(一次修改,多次使用),其他值 "dynamic” 意味着数据可以被频繁修改(多次修改,多次使用),"stream”意味着数据每帧都不同(一次修改,一次使用)。"Draw”意味着数据将会被送往GPU进行绘制,其他值 "read”意味着数据会被用户的应用读取,"copy”意味着数据会被用于绘制和读取。(还有一些其他usage取值)

注意在使用VBO时,只有“Draw”是有效的,而“copy”和“read”主要将会在像素缓冲区(PBO)和帧缓冲区(FBO)中发挥作用。

【每个缓冲区】都需要在[ 顶点着色器 ]中声明的相应的【顶点属性变量】

用来接收立方体顶点的顶点属性可以在顶点着色器中这样声明:

layout(location = 0) in vec3 position;

“in”:输入,表示这个顶点属性将会从缓冲区中接收数值(顶点属性也可以用来“输出”)。
“vec3”:着色器的每次调用会抓到3个浮点类型数值(分别表示x、y、z,它们组成一个顶点数据)。
“position”:变量的名字。
“layout(location=0)”:“layout修饰符”,是把顶点属性和特定缓冲区关联起来的方法,在这里这个顶点属性的[识别号]是0。

假设立方体的顶点数据在C++/OpenGL应用程序中由数组直接指定。在这种情况下,我们需要:
(1) 将这些值复制到之前生成的两个缓冲区中的一个之中。这此,需要使用glBindBuffer()命令将缓冲区(如,第1个缓冲区vbo[0]) 标记为“活跃”。
(2) 使用glBufferData()命令将包含顶点数据的数组复制进活跃缓冲区(这里应该是第1个VBO)。

假设顶点存储在名为vertexArr的浮点类型数组中,以下C++代码会将[这些值]复制到第1个VBO中:

glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);// 标记为“活跃”
glBufferData(GL_ARRAY_BUFFER, sizeof(vertexArr), vertexArr, GL_STATIC_DRAW);// 复制进活跃缓冲区

接下来,我们向display()中添加代码,将【缓冲区】中的值【发送到】着色器中的【顶点属性】
我们通过以下3个步骤来实现:
(1) 使用glBindBuffer()命令标记这个缓冲区为“活跃”;
(2) 将活跃缓冲区与着色器中的顶点属性相关联;
(3) 启用顶点属性。

glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);// (1):标记第1个VBO缓冲区为“活跃”
glVertexAttribPointer(【0】, 3, GL_FLOAT, GL_FALSE, 0, 0);// (2):将标识号为【0】的顶点属性关联到缓冲区
glEnableVertexAttribArray(【0】);// (3):启用标识号为【0】的顶点属性
// GLSL
layout(location = 【0】) in vec3 position;

注意,上述代码中C++和GLSL代码中用【】括起来的数值是相互关联的,都代表顶点属性索引值,所以它们是相同的值。

void glVertexAttribPointer(
GLuint index,// 指定要修改的通用顶点属性的索引。
GLint size,// 指定每个通用顶点属性的组件数量。必须是1 2 3 4。此外,符号常量GL_BGRA被接受。初始值为4。
GLenum type,// 指定数组中每个组件的数据类型。
GLboolean normalized,// 指定定点数据值在访问时是应该被规范化(GL_TRUE)还是直接转换为定点值(GL_FALSE)。
GLsizei stride,// 指定连续的通用顶点属性之间的字节偏移量。如果stride为0,则一般的顶点属性可以被理解为紧密地打包在数组中。初始值为0。
const void * pointer// 指定当前绑定到GL_ARRAY_BUFFER目标的缓冲区的数据存储数组中第一个通用顶点属性的第一个组件的偏移量。初始值为0。
)
定义一个通用顶点属性数据数组。

当执行glDrawArrays()时,第1个VBO中的数据将被传输给拥有位置(location=【0】)的layout修饰符的顶点属性中。这会将立方体的顶点数据发送到着色器

统一变量

要想渲染一个场景以使它看起来是3D的,需要构建适当的变换矩阵,并将它们应用于模型的每个顶点。
而在顶点着色器中应用所需的矩阵运算是最有效的。所以,习惯上会将这些[矩阵]从C++/OpenGL应用程序发送给着色器中的统一变量。

使用“uniform”关键字在着色器中声明统一变量。
以下示例声明了用于存储模型-视图和投影矩阵的变量,足够立方体程序使用:

uniform mat4 mv_matrix;// 用来保存模型-视图矩阵MV
uniform mat4 proj_matrix;// 用来保存投影矩阵([projection:投影])

将数据从C++/OpenGL应用程序发送到统一变量需要执行以下步骤:
(1) 获取统一变量的引用;
(2) 将指向所需数值的指针与获取的统一引用相关联。

假设[链接的渲染程序]保存在名为“renderingProgram”的变量中,把模型-视图和投影矩阵发送到两个统一变量mv_matrix和proj_matrix中:

mvLoc = glGetUniformLocation(renderingProgram, "mv_matrix");// 返回着色器程序中统一变量的位置,存进GLint类型的mvLoc变量中
projLoc = glGetUniformLocation(renderingProgram, "proj_matrix");
glUniformMatrix4fv(mvLoc, 1, GL_FALSE, glm::value_ptr(mvMat);// 将矩阵数据发送到GLSL类型为mat4的统一变量中
glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(pMat);

上述程序代码中,mvMat和pMat是利用GLM工具构建的模型-视图和投影矩阵。GLM调用函数value_ptr()返回对矩阵数据的引用。glUniformMatrix4fv()需要将这些矩阵值传递给统一变量(这里统一变量是mat4类型)。

void glUniformMatrix4fv(
GLint location,// 指定要修改的统一变量的位置。
GLsizei count, // 对于向量(glUniform*v)命令,指定要修改的元素的数量。如果目标统一变量不是数组,则为1;如果是数组,则为1或更多。对于矩阵(glUniformMatrix*)命令,指定要修改的矩阵的数量。如果目标统一变量不是一个矩阵数组,这个值应该是1,如果它是一个矩阵数组,这个值应该是1或更多。
GLboolean transpose, // 对于矩阵命令,指定是否在将值加载到统一变量时对矩阵进行转置。
const GLfloat *value// 对于向量和矩阵命令,指定一个指向计数值数组的指针,该数组将用于更新指定的统一变量。(注:“数组”指的是向量数组或矩阵数组)
)
为当前程序对象指定一个统一变量的值。


genType::value_type const* glm::value_ptr(genType const& v)
返回输入参数 v 的常量地址。
如,glUniformMatrix4fv(…, const GLfloat* value) 函数的 value 参数将接收通过glm:value_ptr(mvMat)返回的矩阵的地址。

统一变量的行为类似于初始化过的常量,并且在每次顶点着色器调用(即从缓冲区发送的每个顶点)中保持不变。统一变量本身不是插值的;无论有多少个顶点,它始终包含相同的值。

顶点属性插值(光栅着色器)

统一变量的行为类似于初始化过的常量,并且在每次顶点着色器调用(即从缓冲区发送的每个顶点)中保持不变。统一变量本身不是插值的;无论有多少顶点,它始终包含相同的值。

在片段着色器光栅化之前,由顶点定义的图元(例如,三角形)被转换为片段。光栅化过程会线性插值顶点属性值,以便显示的像素能无缝连接建模的曲面。

光栅着色器对顶点属性进行的插值在很多方面都很有用。我们将使用光栅化来插值颜色、纹理坐标和曲面法向量。
重要的是要理解:通过缓冲区发送到【顶点属性】的【所有值】【都将在管线中被进一步插值】。

in表示它们从缓冲区接收值;out表示它们会将值发送到管线中的下一个阶段。
OpenGL有一个内置的vec4类型的“out”变量gl_Position。在顶点着色器中,将矩阵变换应用于传入的顶点(之前声明为位置的顶点),并将结果赋值给gl_Position:

// layout(location = 0) in vec3 【position】;
// out vec4 【gl_Position】;// default!(can ignore)
gl_Position = proj_matrix * mv_matrix * position;

然后,变换后的顶点将【自动】输出到[光栅着色器],【最终】将相应的 [像素位置] 发送到[片段着色器]。
OpenGL管线:[顶点着色器] -> [光栅化] -> [片段着色器] -> [像素操作]

在glDrawArrays()函数中指定GL_TRIANGLES时,光栅化是逐个三角形完成的。

插值逻辑:
首先沿着连接顶点的线开始插值,其精度级别和像素显示密度相关,然后通过沿连接边缘像素的水平线插值来填充三角形的内部空间中的像素。

模型-视图和透视矩阵

渲染3D对象的一个基础步骤是创建适当的变换矩阵并将它们发送到统一变量。首先定义3个矩阵:

  • 一个模型矩阵;
  • 一个视图矩阵;
  • 一个透视矩阵。

模型矩阵M在世界空间中表示对象的位置和朝向。每个模型都有自己的模型矩阵,如果模型移动,则需要不断重建该矩阵。
视图矩阵V在世界空间中移动并旋转模型,以模拟相机在所需位置的效果。

OpenGL相机在位置(0, 0, 0)并且面向负Z轴。为了模拟以某种方式移动相机的表现,需要向相反的方向移动物体本身。
例如,将摄像机向右移动会导致场景中的物体看起来是向左移动;虽然OpenGL相机是固定的,但我们可以通过把对象向左移动的方式,让摄像机看起来向右移动了。(这也为什么矩阵变换是“[负]相机位置/旋转角”的原因,上一章已有讨论过)

透视矩阵P是一种变换,它根据所需的视锥提供3D效果
永远不会改变的矩阵可以在 init()中构建,但那些会改变的矩阵需要在 display()中构建,以便为每个帧重建它们。

我们假设模型是动画的相机是可移动的,那么:

  • 需要为每个模型和每个帧都创建模型矩阵;
  • 视图矩阵需要每帧创建一次(因为相机可以移动),但是对于在这一帧期间渲染的所有对象,它都是一样的;
  • 透视矩阵只需要创建一次[在init()中],它需要使用屏幕窗口的宽度和高度(以及所需的视锥体参数),除非调整窗口大小,否则它通常保持不变。

然后在display()中生成模型和视图转换矩阵:
(1) 根据所需的摄像机位置和朝向构建视图矩阵;
(2) 对于每个模型,进行以下操作:
i. 根据模型的位置和朝向构建模型矩阵;
ii. 将模型和视图矩阵结合成单个“MV”矩阵;
iii. 将MV和投影矩阵发送到相应的着色器统一变量。

将模型和视图矩阵合并成一个矩阵,并保持透视矩阵分离
由于复杂的模型可能有数百甚至上千个顶点,因此可以通过将模型和视图矩阵发送到顶点着色器之前预先相乘一次来提高性能。
之后,我们会看到为什么需要将透视矩阵分开以用于光照的目的。

整个流程如下图:

第一个3D程序——一个3D立方体

// vertShader.glsl
#version 430
layout(location = 0) in vec3 position;// 声明识别号为【0】的顶点属性
uniform mat4 mv_matrix;// 声明mat4类型的统一变量
uniform mat4 proj_matrix;// 声明mat4类型的统一变量
void main(void) {/* 内置变量gl_Position用来设置顶点在3D空间的坐标位置,并发送至下一个管线阶段(out)[式1]: vec4(position, 1.0)=vec4(vec3(x, y, z), 1.0) =>将坐标齐次化[式2]: mv_matrix * [式1] =>得到物体在相机空间中的位置坐标proj_matrix * [式2] =>得到透视化的物体位置坐标*/gl_Position = proj_matrix * mv_matrix * vec4(position, 1.0);
}// fragShader.glsl
#version 430
out vec4 color;
void main(void) {/*接收上一管线阶段(顶点着色器)out变量gl_Position传来的位置数据,先经过[光栅化]处理,然后对每一个像素位置都上色为红色,并发送到下一管线阶段(out) -> 像素操作*/color = vec4(1.0, 0.0, 0.0, 1.0);
}
#include <GL\glew.h>
#include <GLFW\glfw3.h>
#include <string>
#include <iostream>
#include <fstream>
#include <cmath>
#include <glm\glm.hpp>
#include <glm\gtc\type_ptr.hpp> // glm::value_ptr
#include <glm\gtc\matrix_transform.hpp> // glm::translate、rotate、scale、perspective
#include "Utils.h"
using namespace std;#define numVAOs 1
#define numVBOs 2// 对于本例,只需一个VBOfloat cameraX, cameraY, cameraZ;
float cubeLocX, cubeLocY, cubeLocZ;
GLuint renderingProgram;
GLuint vao[numVAOs];
GLuint vbo[numVBOs];// variable allocation for display
GLuint mvLoc, projLoc;
int width, height;
float aspect;
glm::mat4 pMat, vMat, mMat, mvMat;// 注:关于Utils.h、Utils.cpp,可下载原书源码,这里不作介绍,总之就是封装好的一些方法。
Utils util = Utils();void setupVertices() {/*OpenGL工作在一个叫做NDC(Normalized Device Coordinates)的坐标系统下,在这个坐标系统中,x、y和z的值全部都坐落在[-1, +1]范围内,超出这个范围的点会被OpenGL忽略。因为我们直接使用OpenGL的NDC坐标系统,所以顶点坐标的值在-1到+1之间。*/float vertexPositions[108] = {// 108÷3=36个点-1.0f,  1.0f, -1.0f, -1.0f, -1.0f, -1.0f, 1.0f, -1.0f, -1.0f,1.0f, -1.0f, -1.0f, 1.0f,  1.0f, -1.0f, -1.0f,  1.0f, -1.0f,1.0f, -1.0f, -1.0f, 1.0f, -1.0f,  1.0f, 1.0f,  1.0f, -1.0f,1.0f, -1.0f,  1.0f, 1.0f,  1.0f,  1.0f, 1.0f,  1.0f, -1.0f,1.0f, -1.0f,  1.0f, -1.0f, -1.0f,  1.0f, 1.0f,  1.0f,  1.0f,-1.0f, -1.0f,  1.0f, -1.0f,  1.0f,  1.0f, 1.0f,  1.0f,  1.0f,-1.0f, -1.0f,  1.0f, -1.0f, -1.0f, -1.0f, -1.0f,  1.0f,  1.0f,-1.0f, -1.0f, -1.0f, -1.0f,  1.0f, -1.0f, -1.0f,  1.0f,  1.0f,-1.0f, -1.0f,  1.0f,  1.0f, -1.0f,  1.0f,  1.0f, -1.0f, -1.0f,1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f,  1.0f,-1.0f,  1.0f, -1.0f, 1.0f,  1.0f, -1.0f, 1.0f,  1.0f,  1.0f,1.0f,  1.0f,  1.0f, -1.0f,  1.0f,  1.0f, -1.0f,  1.0f, -1.0f};glGenVertexArrays(numVAOs, vao);// 创建1个VAO,对象ID存进vao[0]glBindVertexArray(vao[0]);// 将第1个VAO标记为“活跃”glGenBuffers(numVBOs, vbo);// 创建2个VBO,返回2个对象ID分别存进vbo[0]、vbo[1]glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);// 将第1个VBO标记为“活跃”glBufferData(GL_ARRAY_BUFFER, sizeof(vertexPositions), vertexPositions, GL_STATIC_DRAW);// 将顶点数组数据复制进“活跃”缓冲区vbo[0]
}void init(GLFWwindow* window) {/*Utils::createShaderProgram()函数内容:glCreateShader 创建着色器句柄glCreateProgram 创建着色器程序对象glShaderSource 关联着色器句柄与GLSL代码glCompileShader 编译着色器句柄glAttachShader 将着色器附加到程序对象glLinkProgram 链接程序对象*/renderingProgram = Utils::createShaderProgram("vertShader.glsl", "fragShader.glsl");// 以下为指定的camera的坐标位置,它在OpenGL默认方向为看向Z轴负方向cameraX = 0.0f; cameraY = 0.0f; cameraZ = 8.0f;cubeLocX = 0.0f; cubeLocY = -2.0f; cubeLocZ = 0.0f;setupVertices();
}void display(GLFWwindow* window, double currentTime) {glClear(GL_DEPTH_BUFFER_BIT);// 清除深度缓冲区glUseProgram(renderingProgram);// 使用着色器程序对象mvLoc = glGetUniformLocation(renderingProgram, "mv_matrix");// 获取着色器程序中统一变量的位置projLoc = glGetUniformLocation(renderingProgram, "proj_matrix");glfwGetFramebufferSize(window, &width, &height);// 获取window窗口的帧缓冲区的大小。这样一来,当窗口大小改变时,aspect参数也会变化。aspect = (float)width / (float)height;/*透视矩阵参数:<fieldOfView>=FOV角,<aspectRatio>=W/H,<nearPlane>=Znear,<farPlane>=ZfarY方向视场角=60°,宽高比=aspect,近剪裁平面=0.1,远剪裁平面=1000*/pMat = glm::perspective(1.0472f, aspect, 0.1f, 1000.0f);/* 视图矩阵:构建由3个分量的向量创建的4×4平移矩阵。(世界空间中)通过translate()后,数值用常规矩阵表示为:1  0  0  1          1  0  0  -cameraX0  1  0  1             0  1  0  -cameraY0  0  1  1             0  0  1  -cameraZ0  0  0  1  => vMat= 0  0  0     1注:对相机坐标进行操作并不是要平移相机,相机坐标位置是固定的,目的是为了得到视图矩阵vMat,用它来平移物体到相机空间。*/vMat = glm::translate(glm::mat4(1.0f), glm::vec3(-cameraX, -cameraY, -cameraZ));/*模型矩阵:1  0  0  cubeLocX        1  0  0  00  1  0  cubeLocY     0  1  0 -20  0  1  cubeLocZ     0  0  1  00  0  0     1       ==  0  0  0  1  */mMat = glm::translate(glm::mat4(1.0f), glm::vec3(cubeLocX, cubeLocY, cubeLocZ));mvMat = vMat * mMat;// 构建一个模型-视图矩阵【注:乘数与被乘数顺序不能反】// 将mvMat、pMat矩阵数据发送到GLSL类型为mat4的统一变量中glUniformMatrix4fv(mvLoc, 1, GL_FALSE, glm::value_ptr(mvMat));glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(pMat));glVertexAttribPointer(0, 3, GL_FLOAT, false, 0, 0);// 将顶点属性的识别号为【0】的属性关联到缓冲区(第1个VBO:vbo[0])glEnableVertexAttribArray(0);// 启用识别号为【0】的顶点属性。对应于GLSL:layout(location=【0】)glEnable(GL_DEPTH_TEST);// 启用深度测试glDepthFunc(GL_LEQUAL);/* 当glDrawArrays()被执行时,缓冲区中的数据开始流动,从缓冲区的开头开始,按顺序流过顶点着色器,顶点着色器对每个顶点执行一次。以三角形为基元,从第0个点开始,共绘制36个顶点。以12个三角形组成一个立方体*/glDrawArrays(GL_TRIANGLES, 0, 36);
}int main(void) {if (!glfwInit()) { exit(EXIT_FAILURE); }glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);GLFWwindow* window = glfwCreateWindow(600, 600, "Cube", NULL, NULL);glfwMakeContextCurrent(window);if (glewInit() != GLEW_OK) { exit(EXIT_FAILURE); }glfwSwapInterval(1);init(window);while (!glfwWindowShouldClose(window)) {display(window, glfwGetTime());glfwSwapBuffers(window);glfwPollEvents();}glfwDestroyWindow(window);glfwTerminate();exit(EXIT_SUCCESS);
}

程序解读:
display()可以被重复调用,并且调用它的速率被称为【帧率】。也就是说,通过不断地快速绘制和重绘场景或帧,就可以实现动画。

glEnableVertexAttribArray(index);启用常规顶点属性数组。启用由index指定的常规顶点属性数组。它使用当前绑定的顶点数组对象进行操作。

通常需要在渲染帧之前清除深度缓冲区,以便正确地进行隐藏面消除。默认情况下,OpenGL深度值范围为0.0~1.0。调用glClear(GL_DEPTH_BUFFER_BIT);就可以清除深度缓冲区,这通常会使用默认值1.0来填充深度缓冲区。

通过调用glUseProgram()来启用着色器,在GPU上安装GLSL代码。这并不会运行着色器程序,但它会让后续的OpenGL调用能够确定着色器的顶点属性和统一变量位置。

mat<4,4,T,Q> glm::translate(mat<4,4,T,Q> const& m, vec<3,T,Q> const& v)
从3元素的向量创建一个4×4的转换矩阵。
m - 输入矩阵乘以这个平移矩阵。
v - 平移向量的坐标。

glm::mat4 m = glm::translate(glm::mat4(1.0f), glm::vec3(x,y,z));
打印变量m的所有值:
(可以直接对mat4对象使用和二维数组一样的方式用下标m[i][j]访问所有元素,因为ma4类重载了[]运算符)
m[0][0] == 1.0f, m[0][1] == 0.0f, m[0][2] == 0.0f, m[0][3] == 0.0f
m[1][0] == 0.0f, m[1][1] == 1.0f, m[1][2] == 0.0f, m[1][3] == 0.0f
m[2][0] == 0.0f, m[2][1] == 0.0f, m[2][2] == 1.0f, m[2][3] == 0.0f
m[3][0] == x    , m[3][1] == y     , m[3][2] == z    , m[3][3] == 1.0f
由于GLM中是按【列】组织数据的,即[X][Y]:第X-1列的第Y-1个元素。
常规表示法为:
1 0 0 x
0 1 0 y
0 0 1 z
0 0 0 1 (刚好满足平移变换矩阵格式)
示例中vMat = glm::translate(glm::mat4(1.0f), glm::vec3(-cameraX, -cameraY, -cameraZ));
构建了一个平移变换矩阵:通过单位矩阵(由glm::mat4(1.0f)构造) 与平移向量(由glm::vec3(x, y, z)构造),构造出一个4×4平移变换矩阵。

position 顶点属性被声明为 vec3 类型,因此需要将其转换为 vec4 类型,以便与将要用它乘以的 4× 4 矩阵兼容。这个转换是用 vec4(position,1.0)完成的。

顶点着色器中的乘法将矩阵变换应用于顶点,将其转换为相机空间(请注意从右到左的结合顺序)。这些值被放入内置的 OpenGL 输出变量 gl_Position 中,然后继续通过管线并由光栅着色器进行插值。然后插值后的像素位置(称为片段)被发送到片段着色器。片段着色器逐个处理像素,并为每个像素单独调用。

glDepthFunc(GLenum func)
指定用于将每个传入像素深度值与深度缓冲区中的深度值进行比较的函数。
func - 指定深度比较函数(默认值是GL_LESS)。仅当启用深度测试(glEnable(GL_DEPTH_TEST))时才执行比较。
GL_LESS:如果传入的深度值小于缓冲区中的深度值,则传递(即被绘制)。
GL_LEQUAL:如果传入的深度值小于[或等于]缓冲区中的深度值,则传递(即被绘制)。
还有其他常量:GL_NEVER,GL_EQUAL,GL_GREATER,GL_NOTEQUAL,GL_GEQUAL,GL_ALWAYS。

深度缓冲区相关知识,请见博客“✠OpenGL-2-图像管线”。

void glfwGetFramebufferSize(GLFWwindow* window, int* width, int* height)
window - [in] 要查询帧缓冲区的窗口。
width - [out] 存储帧缓冲区的宽度(以像素为单位)或NULL。
height - [out] 存储帧缓冲区的高度(以像素为单位)或NULL。
这个函数获取指定窗口的帧缓冲区的大小(以像素为单位),存储于width和height。

将数组vertexPositions中所有坐标的绝对值修改为蓝色字体表示的值时,效果分别如下:

示例中通过数组vertexPositions在世界空间绘制出来会是一个中心点在原点(0, 0, 0)位置的立方体,而立方体要从世界空间最终变换到相机空间,通过如下代码,打印出mvMat,即模型-视图矩阵的值,而立方体最终在【相机空间】中的位置是经过mvMat变换后得到的

打印[模型-视图矩阵]对象
// cameraX = 0.0f; cameraY = 0.0f; cameraZ = 8.0f;
// cubeLocX = 0.0f; cubeLocY = -2.0f; cubeLocZ = 0.0f;
for (int i = 0; i < 4; i++)for (int j = 0; j < 4; j++) {cout << "[" << i << "]" << "[" << j << "]=" << mvMat[i][j] << " ";if (j == 3)cout << endl;}
[0][0]=1 [0][1]=0  [0][2]=0  [0][3]=0                1   0   0   0
[1][0]=0 [1][1]=1  [1][2]=0  [1][3]=0               0   1   0  -2
[2][0]=0 [2][1]=0  [2][2]=1  [2][3]=0               0   0   1  -8
[3][0]=0 [3][1]=-2 [3][2]=-8 [3][3]=1   即,mvMat=    0   0   0   1
经mvMat矩阵变换后,模型所有顶点就从局部空间(物体空间)转换到相机空间了。

由于GLM中是按【列】组织数据的,所以mvMat矩阵为:
[1000010−2001−80001]\left[ \begin{array} { l l l l } { 1 } & { 0 } & { 0 } & { 0 } \\ { 0 } & { 1 } & { 0 } & { -2 } \\ { 0 } & { 0 } & { 1 } & { -8 } \\ { 0 } & { 0 } & { 0 } & { 1 } \end{array} \right] ⎣⎢⎢⎡​1000​0100​0010​0−2−81​⎦⎥⎥⎤​
对比平移变换矩阵公式:
(X+TxY+TyZ+Tz1)=[100Tx010Ty001Tz0001]×(XYZ1)\left( \begin{array} { l } { X+T_x } \\ { Y+T_y } \\ { Z+T_z } \\ { 1 } \end{array} \right) = \left[ \begin{array} { l l l l } { 1 } & { 0 } & { 0 } & { T_x } \\ { 0 } & { 1 } & { 0 } & { T_y } \\ { 0 } & { 0 } & { 1 } & { T_z } \\ { 0 } & { 0 } & { 0 } & { 1 } \end{array} \right] \times \left( \begin{array} { l } { X } \\ { Y } \\ { Z } \\ { 1 } \end{array} \right) ⎝⎜⎜⎛​X+Tx​Y+Ty​Z+Tz​1​⎠⎟⎟⎞​=⎣⎢⎢⎡​1000​0100​0010​Tx​Ty​Tz​1​⎦⎥⎥⎤​×⎝⎜⎜⎛​XYZ1​⎠⎟⎟⎞​
可知,立方体要变换到【相机空间】得从它最初构建的位置(0, 0, 0)向Y轴负方向移动单位2,同时向Z轴负方向移动单位8,即立方体中心点在相机空间里的坐标值为(0, -2, -8)。与理论和实际情况是相符的。

分析:物体经模型-视图矩阵变换后的坐标值是[相机空间]的坐标值
cameraX = 0.0f; cameraY = 0.0f; cameraZ = 8.0f;
cubeLocX = 0.0f; cubeLocY = -2.0f; cubeLocZ = 0.0f;
vMat = glm::translate(glm::mat4(1.0f), glm::vec3(-cameraX, -cameraY, -cameraZ));
mMat = glm::translate(glm::mat4(1.0f), glm::vec3(cubeLocX, cubeLocY, cubeLocZ));
mvMat = vMat * mMat;// 构建一个模型-视图矩阵【注:乘数与被乘数顺序不能反】

相机从(0, 0, 0)变换到世界空间中的坐标值(0, 0, 8),由于不存在旋转相机操作,所以通过translate()函数轻易得到负相机平移变换矩阵vMat(视图矩阵,目的是将模型从[世界空间]变换到[相机空间])。立方体最初的中心点是在局部坐标空间中的坐标原点(0, 0, 0),经translate()函数轻易得到模型矩阵mMat(模型矩阵,目的是将模型从[局部空间]变换到[世界空间]),再经vMat变换后,得到模型中心点在相机空间中的坐标值为(0, -2, -8)。【模型[局部空间]—mMat—>[世界空间]—vMat—>[相机空间]】
直观解释如下:
我们知道,相机和物体在世界空间中的位置都肯定是不变的,我们要求出的只是以相机为坐标系原点时的物体坐标位置。因为相机与物体位置固定不变,所以他们的相对位置恒定不变(在Z轴上的带符号距离恒为Z相机-Z立方体=8-0=8,即Z立方体=Z相机-8)。现在相机坐标从(0, 0, 8)变为(0, 0, 0),即Z相机New=0,那么Z立方体New=Z相机New-8=-8。对于X、Y轴平移是一样的道理,对于旋转操作也一样的原理,一个是固定的相对位置、一个是固定的相对角度而已。
物体与相机本身就固定不动,所以当然经变换后的坐标值是[相机空间]的坐标值了。

由于OpenGL相机默认 [位置] 固定在点(0, 0, 0),[方向] 为看向Z轴负方向。
而我们指定的相机位置为(0, 0, 8),方向未指定(未旋转相机),即为默认看向Z轴负方向,看向位于(0, -2, 0)的立方体,如下图:

为什么取Znear=0.1,Zfar=1000,FOV角=60°呢?

从上图我们可以看到,对于一个视锥体(平截头体)而言,Znear与Zfar越相近,这个梯形体就越接近一个正方体,这就导致透视效果越不明显(临界情况下是正方体,透视效果为0),所以Zfar - Znear要很大,以表现出明显的透视效果。那为什么Znear如此小而Zfar又如此大呢?因为相机摄像头就相当于自己的眼睛,Znear就相当于眼睛前面一堵墙上的一个开孔窗户距离自己眼睛的距离,这个窗户离自己越远,自己的视野当然就越窄,越贴近窗口,能看到的东西越多,视野越广,所以Znear这个值不能大。Zfar也是一样的道理,Zfar越大,看到的景越深,所以Zfar这个值要大。FOV角,也是影响视野的一个因素,这个值越大,视野越广;但如果值太大,就相当于坐第一排观看电影一样,有种眼睛的逼迫感;如果值太小,就相当于看演唱会站在最后一排,景深有了,但歌星显得格外小,影响现场观看体验。所以,FOV角的值要适当。

近剪裁平面那么小,有什么不对吗?

自己的眼睛那么小,看到的东西仍然是它原本的尺寸,这是因为远处的物体投影到了自己的眼睛,视野范围内的世间万物那么多景全部投影到那么小的眼球上,然后在脑海里构建出整个有透视的场景。所以近剪裁平面的大小并没有怎么影响到对世界的观感。眼睛好比近剪裁平面;脑海中呈现物体尺寸,好比OpenGL窗口中显示的物体模型尺寸。上图中也给出了H=0.058,windowH=600时,在垂直方向上放大了10345倍。相机摄像头只是眼睛,底片显示二维像素图(假设原像素大小为1080*1920pixl)。就像电视机有竖屏和横屏尺寸的,但最终的效果由播放器的设置等比例缩放(如下图),其他地方呈现为黑色。

由于在display()函数中,有如下代码,导致改变窗口大小时aspect值会变化,从而影响透视效果。

glfwGetFramebufferSize(window, &width, &height);
aspect = (float)width / (float)height;
改变物体尺寸及视口尺寸对物体呈现的影响

透视矩阵:
[A0000q0000BC00−10]\left[ \begin{array} { l l l l } { A } & { 0 } & { 0 } & { 0 } \\ { 0 } & { q } & { 0 } & { 0 } \\ { 0 } & { 0 } & { B } & { C } \\ { 0 } & { 0 } & { -1 } & { 0 } \end{array} \right] ⎣⎢⎢⎡​A000​0q00​00B−1​00C0​⎦⎥⎥⎤​
其中,
q=1tan⁡(fieldOfView2)A=qaspectRatio▫B=Znear+ZfarZnear−ZfarC=2∗(Znear∗Zfar)Znear−Zfarq = \frac { 1 } { \operatorname { tan } ( \frac { fieldOfView } { 2 } ) }\qquad A = \frac { q } { aspectRatio }\qquad \\ \\▫ \\ B = \frac {Z_{near} + Z_{far}} {Z_{near} - Z_{far}}\qquad C = \frac {2*(Z_{near}*Z_{far})} {Z_{near} - Z_{far}} q=tan(2fieldOfView​)1​A=aspectRatioq​▫B=Znear​−Zfar​Znear​+Zfar​​C=Znear​−Zfar​2∗(Znear​∗Zfar​)​
则对物体应用透视矩阵后:
(X′Y′Z′1)=[A0000q0000BC00−10]×(XYZ1)=(AXqYBZ+C−Z)\left( \begin{array} { l } { X' } \\ { Y' } \\ { Z' } \\ { 1 } \end{array} \right) = \left[ \begin{array} { l l l l } { A } & { 0 } & { 0 } & { 0 } \\ { 0 } & { q } & { 0 } & { 0 } \\ { 0 } & { 0 } & { B } & { C } \\ { 0 } & { 0 } & { -1 } & { 0 } \end{array} \right] \times \left( \begin{array} { l } { X } \\ { Y } \\ { Z } \\ { 1 } \end{array} \right) = \left( \begin{array} { l } { AX } \\ { qY } \\ { BZ+C } \\ { -Z } \end{array} \right) ⎝⎜⎜⎛​X′Y′Z′1​⎠⎟⎟⎞​=⎣⎢⎢⎡​A000​0q00​00B−1​00C0​⎦⎥⎥⎤​×⎝⎜⎜⎛​XYZ1​⎠⎟⎟⎞​=⎝⎜⎜⎛​AXqYBZ+C−Z​⎠⎟⎟⎞​
由以上公式可知:
X′=1aspectRatio×tan⁡(fieldOfView2)×X▫Y′=1tan⁡(fieldOfView2)×Y▫Z′=B×Z+CX' = \frac{1}{aspectRatio\times \operatorname { tan } (\frac{fieldOfView}{2})}\times X\\ \\▫ \\ Y' = \frac { 1 } { \operatorname { tan } ( \frac { fieldOfView } { 2 } ) }\times Y\\ \\▫ \\ Z' = B\times Z + C X′=aspectRatio×tan(2fieldOfView​)1​×X▫Y′=tan(2fieldOfView​)1​×Y▫Z′=B×Z+C

Z轴影响的是透视关系,Z = BZ + C,B、C是常量,且B、C小于0,所以Z = -|B|Z - |C|,说明在Z轴上线性变化。由B、C值的公式可知,当距离无限远时,Zfar - Znear = Zfar,B=-1,C=-2Znear=-2×0.1≈0,Z=-Z。(我们知道透视原理:所有在深度方向上的原平行线都在无限远处相交于同一点。)此例中在无限远处相交于Z轴上的一点,即随着深度增加,在视口二维平面上显示的像素(X, Y)以斜率k=-1=tan45°线性变小。

➀当所有坐标的绝对值为1.0f时,拖动改变窗口大小,效果如下:

可以看到,初始aspectRatio=W/H=600/600=1,立方本在视口的剪裁近平面是个正方形,从而剪裁远平面也是个正方形。
结合上面公式:
当横向缩放视口时,向右拖大,aspectRatio变大,X变小(物体横向缩小),Y不变,Z影响透视。
当纵向缩放视口时,向下拖大,aspectRatio变小,X变大(物体横向放大),Y不变,Z影响透视。同理,向上施大窗口,物体横向缩小,当窗口纵向接近0时,物体尺寸也接近于0,所以消失。

➁当所有坐标的绝对值为5.0f时,拖动改变窗口大小,效果如下:

我们先看一下视锥平面,如下图:

∠AOB=60°,|OE|=8,物体中心点在点E。通过计算可得立方体前平面(front)到点O的距离为8-5=3。而立方体边长为L=2×5.0=10。所以最初窗口中能看到的只是立方体的局部,这个局部填充满整个窗口。所以看不到透视效果。

图1:

图2:

我们知道,当横向缩放视口时,向右拖大,物体横向缩小;当纵向缩放视口时,向下拖大,物体横向放大。符合预期。

为什么持续向下拖动窗口时,立方体边长到达最大值后,边长就保持不变了,而且立方体一直没有透视,并且aspectRatio变化后,显示的是个正方形而不是矩形?

我们根据例子本身,aspectRatio=W/H=600/H,tan(fieldOfView/2)=tan30°=0.58,B=-1,C=-0.2,这时:X = (H/346)X,Y = 1.73Y,Z = -Z-0.2。
由于Y轴线性恒变,Z轴线性透视。当H变大时,X变小,但由上(图1)和(图2)可知,无论aspectRatio为什么值,立方体中心在视锥体内,但立方体也只有前后这两个平面有部分在视锥体内,如上图2中的橙色正方形所示,改变向下拖动让窗口变大,也就是改变aspectRatio,而最终只会改变显示区域中的立方体前平面的区域方位。显然透视效果是没有的。

因此,程序员应该确保场景必需的所有对象都位于视锥内。

初始窗口尺寸对之后物体呈现的影响
  • 初始窗口尺寸=600×1200时,且所有坐标的绝对值为5.0f时,拖动改变窗口大小,效果如下:
    由于最初aspectRatio=1:1,呈现的是正方形;这时改变aspectRatio,就是在正方形基础上作改变。当aspectRatio=1:2时的效果,如下图:

    可以看到是一个宽高比为1:2的矩形。之后改变aspectRatio,都是在这个矩形基础上的变化。
  • 初始窗口尺寸=600×1200时,且所有坐标的绝对值为1.0f时,拖动改变窗口大小,效果如下:

    可以看到,最初立方体的位置和大小是符合当aspectRatio=1:1时拖动窗口到相应位置的尺寸和方位,但当调整窗口恢复到600×600时,立方体显示与初始设置aspectRatio=1:1时是不一样的。
    从而可知,在本例中,初始化窗口的尺寸很重要,因为窗口尺寸决定了aspectRatio的值,从而初始化了视野及显示的物体(位置和方位)。后期改变aspectRatio不会影响这个初始化的呈现。后期更改窗口尺寸,仍符合公式所表示出的预期的缩放效果。
    如下代码,初始化窗口尺寸是在init()中一次设置完成的,而非在display()中被循环调用。这也佐证了,初始化的窗口尺寸会影响首次呈现。之后就按公式及透射关系显示模型。
GLFWwindow* window = glfwCreateWindow(600, 600, "Cube", NULL, NULL);
glfwMakeContextCurrent(window);
解决改变窗口尺寸时,优化显示区域(glViewport())

如下代码只执行一次,最好放在init()中(如果直接去掉,最初因没调整window大小,window_size_callback没回调,导致初始没有任何画面):

 glfwGetFramebufferSize(window, &width, &height);aspect = (float)width / (float)height;pMat = glm::perspective(1.0472f, aspect, 0.1f, 1000.0f);

添加上glfwSetWindowSizeCallback:

int main() {...glfwSetWindowSizeCallback(window, window_size_callback);...
}
void window_size_callback(GLFWwindow* win, int newWidth, int newHeight) {aspect = (float)newWidth / (float)newHeight;glViewport(0, 0, newWidth, newHeight);pMat = glm::perspective(1.0472f, aspect, 0.1f, 1000.0f);
}

加上以上代码后,效果如下:

模型永远水平居中,显示效果不错。

对于立方体所有顶点坐标绝对值为5.0时,效果如下:

如果注释掉glViewport(0, 0, newWidth, newHeight);拖动窗口大小时模型就不会自动居中显示了。

void glViewport(GLint x, GLint y, GLsizei width, GLsizei height);
x,y - 指定视口矩形的左下角(以像素为单位)。初始值为(0,0)。
width,height - 指定视口的宽度和高度。当GL上下文第一次附着到窗口时,宽度和高度将设置为该窗口的尺寸。
功能:设置视口(viewport)————指定x和y从标准化设备坐标(normalized device coordinates)到窗口坐标(window coordinates)的仿射变换。
标准化设备坐标NDC:是一个x、y在[-1.0...1.0],z在[0...1]的一小段空间。任何落在范围外的坐标都会被丢弃/裁剪,不会显示在你的屏幕上。
设(Xnd, Ynd)为标准化设备坐标。然后窗口坐标(Xw,Yw)计算如下:
Xw=(Xnd + 1) × width / 2 + x
Yw=(Ynd + 1) × height / 2 + y
视口的宽度和高度会自动钳制到一个范围,该范围取决于实现。
由于Xnd、Ynd∊[-1, 1],所以Xnd+1、Ynd+1∊[0, 2],x,y取初始值(0,0),则Xw∊[0, width]、Yw∊[0, height]。这样就完成了(Xnd,Ynd)到(Xw,Yw)的“仿射”。

OpenGL工作在一个叫做NDC(Normalized Device Coordinates)的坐标系统下,在这个坐标系统中,x、y、z的值全部都坐落在[-1, +1]范围内,超出这个范围的点会被OpenGL忽略。

一旦你的顶点坐标已经在【顶点着色器】中处理过,它们就应该是标准化设备坐标(NDC)了。任何落在范围外的坐标都会被丢弃/裁剪,不会显示在你的屏幕上。

生成有插值颜色的立方体


只需对着色器进行一些轻微的修改,就可以完成目标图形。
修改后的顶点着色器和片段着色器:

#version 430
layout(location = 0) in vec3 position;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
out vec4 varyingColor;// 新添加的out变量
void main() {gl_Position = proj_matrix * mv_matrix * vec4(position, 1.0);// 将color取值范围从[-1,+1]转换为[0,1]varyingColor = vec4(position, 1.0) * 0.5 + vec4(0.5, 0.5, 0.5, 0.5);
}#version 430
in vec4 varyingColor;
out vec4 color;
void main() {color = varyingColor;
}

为了使颜色值符合颜色的表示法,即取值要在[0, 1]区间,所以对vec3 position值进行了转化。
由于我们构建的模型工作在一个叫做NDC(Normalized Device Coordinates)的坐标系统下,在这个坐标系统中,x、y、z∈[-1,+1],所以varyingColor.Red、varyingColor.Green、varyingColor.Blue 都等于: (x/2+0.5)∊[0, 1],对应[0x00000000, 0xFFFFFFFF]。

这样一来,对每个顶点都有不同的颜色值。效果如下:

这时注释掉glEnable(GL_DEPTH_TEST);,运行效果如下:

即,不启用深度测试,就没法完成“隐藏面消除”的效果。

旋转动画

先看三个函数(平移、旋转、缩放):

mat4 glm::translate(const mat4& m, const vec3& v)
从一个3组件向量构建一个4×4的平移矩阵。
m-输入矩阵乘以这个平移矩阵。(Input matrix multiplied by this translation matrix.)
v-平移向量的坐标。(Coordinates of a translation vector.)
◆返回一个平移矩阵。第一个参数是目标矩阵,第二个参数是平移的方向向量。
——————————————————————————————————————————
mat4 glm::rotate(const mat4& m, const T angle, vec3& axis)
从一个轴矢量和角度建立一个4×4旋转矩阵。
m-输入矩阵乘以这个旋转矩阵。
angle-以弧度表示的旋转角度。vec glm::degrees(vec const& radians)——弧度转角度(参数要是后缀“f”的浮点数,如:45.0f)vec glm::radians(vec const& degrees)——角度转弧度
axis-旋转轴,建议归一化。
◆返回一个将点绕某个轴旋转(方向:右手定则)一定弧度的旋转矩阵。第一个参数是弧度值,第二个参数是旋转轴。
——————————————————————————————————————————
mat4 glm::scale(const mat4& m, const vec3& v)
构建由3个标量创建的4×4缩放矩阵。
m-输入矩阵乘以这个缩放矩阵。
v-每个轴的缩放比例。
◆返回一个缩放矩阵。第一个参数是目标矩阵,第二个参数是在各坐标轴上的缩放系数。

在display()函数中的代码修改如下:

glClear(GL_COLOR_BUFFER_BIT);
...
// 在X、Y、Z轴方向平移,平移量∈[-2, 2]区间
tMat = glm::translate(glm::mat4(1.0f), glm::vec3(sin(0.35f*currentTime)*2.0f, cos(0.52f*currentTime)*2.0f,sin(0.7f*currentTime)*2.0f));
// 分别绕Y、X、Z轴旋转
rMat = glm::rotate(glm::mat4(1.0f),1.75f*(float)currentTime,// 1.75用来调整旋转速度glm::vec3(0.0f, 1.0f, 0.0f));
rMat = glm::rotate(rMat, 1.75f*(float)currentTime, glm::vec3(1.0f, 0.0f, 0.0f));
rMat = glm::rotate(rMat, 1.75f*(float)currentTime, glm::vec3(0.0f, 0.0f, 1.0f));
// 在X、Y、Z轴方向缩放,缩放比例范围0.5~1.5
sMat = glm::scale(glm::mat4(1.0f),glm::vec3(glm::abs(sin(currentTime)) + 0.5f,glm::abs(sin(currentTime)) + 0.5f,glm::abs(sin(currentTime)) + 0.5f));
// 合并矩阵:
mMat = sMat * tMat * rMat;// 顺序:旋转->平移->缩放

请注意,如下代码的等效代码。不过,一定要理解代码执行的顺序!

rMatY = rotate(mat4(1), N, vec3(0,1,0));// 绕Y轴旋转
rMatX = rotate(rMatY, N, vec3(1,0,0));// [先]绕Y轴旋转后,[再]绕X轴旋转
以上对rMatX的运算,等效于如下运算:
rMatX0 = rotate(mat4(1), N, vec3(1,0,0));// 绕X轴旋转
rMatX = rMatX0 * rMatY;// 矩阵乘法(从右往左),所以也是[先]绕Y轴[再]绕X轴

注意矩阵乘法——操作中 tMat 和 rMat 的顺序很重要。它计算两个变换的结合,在乘积式子中,平移矩阵放在左边,旋转矩阵放在右边。根据GLM矩阵乘法从右往左的运算规则,也就是先旋转,再平移。

因为在GLSL中有:gl_Position = proj_matrix * mv_matrix * vec4(position, 1.0);,所以顶点是先进行mv_matrix变换,而mv_matrix=vMat * mMat,所以mMat中变换的顺序是mMat式子从右往左的矩阵运算,也即从右往左的变换顺序。

变换的应用顺序很重要,改变矩阵乘法因子的顺序会导致不同的行为!
从下图明显可以看到,先平移后旋转与先旋转后平移,最终物体的方位完全不一样了。

来看一下程序运行最终效果:

我们注意到在display()中添加了代码glClear(GL_COLOR_BUFFER_BIT);,假如注释掉这行代码,则运行效果如下:

由此可知,清除颜色缓冲区可以去掉在缓冲区的颜色,从而达到去掉残影的效果。

cameraZ的取值对视野的影响


由上图,可以看到cameraZ=32时比cameraZ=8时视野要大很多。
即,相机在Z轴正方向的值越大,视野越开阔,相应的物体显示的深度越深。
对本例而言,将摄像机沿着Z轴再移远一些,以看到更多的立方体。

渲染一个对象的多个副本

在我们解决在单个场景中渲染多种不同的模型的常见情况之前,让我们先考虑更简单的情形——同一模型多次出现。
我们可以将 display()函数中构建 MV 矩阵并绘制立方体的代码,移动到一个执行24次的循环中来完成此操作。我们利用循环变量来计算立方体的旋转和平移参数,以便每次绘制立方体时,都会构建不同的模型矩阵。

原来摄像机放在(0, 0, 8)的位置,现在将摄像机放置在正Z轴的(0, 0, 32)位置,这样我们就可以看到所有的立方体。

下面将有修改的代码列出:

void display(GLFWwindow* window, double currentTime) {...for (i=0; i < 24; i++) {tf = currentTime + i;// tf:"time factor"时间因子,声明为浮点类型tMat = glm::translate(glm::mat4(1.0f), glm::vec3(sin(0.35f*tf)*8.0f,cos(0.52f*tf)*8.0f,sin(0.70f*tf)*8.0f));rMat = glm::rotate(glm::mat4(1.0f), 1.75f*tf, glm::vec3(0.0f, 1.0f, 0.0f);rMat = glm::rotate(rMat, 1.75f*tf, glm::vec3(1.0f, 0.0f, 0.0f);rMat = glm::rotate(rMat, 1.75f*tf, glm::vec3(0.0f, 0.0f, 1.0f);mMat = tMat * rMat;mvMat = vMat * mMat;...}
}

运行效果如下:

实例化

实例化(Instancing)提供了一种机制,可以只用 [一个] C++/OpenGL调用就告诉【显卡】渲染一个对象的多个副本,这就是与上面创建一个对象的多个副本用循环实现的不同之处,因为这里不再需要循环语句了。这可以带来显著的性能好处,特别是当有数千甚至数百万的对象被绘制时——例如渲染在场地中的许多花朵的时候。

void glDrawArraysInstanced(GLenum mode, GLint first, GLsizei count, GLsizei instancecount)
mode-指定要呈现的原语类型。接受符号常量GL_POINTS、GL_LINE_STRIP、GL_LINE_LOOP、GL_LINES、GL_TRIANGLE_STRIP、GL_TRIANGLE_FAN、GL_TRIANGLES、GL_LINES_ADJACENCY、GL_LINE_STRIP_ADJACENCY、GL_TRIANGLES_ADJACENCY、GL_TRIANGLE_STRIP_ADJACENCY、GL_PATCHES。
first-指定启用阵列中的起始索引。
count-指定要渲染的索引数。与glDrawArrays()的第3个参数意义相同,即顶点的数量。
instancecount-指定要呈现的指定索引范围的实例数。glDrawArraysInstanced的行为与glDrawArrays相同,
除了元素范围的instancecount实例会被执行,并且内部计数器instanceID的值会在每次迭代中前进。
instanceID是一个内部32位整数计数器,可以被[顶点着色器]读取为gl_InstanceID。
——————————————————————————————————————————
与glDrawArraysInstanced具有相同的效果的代码:if (mode or count is invalid) {generate appropriate error
} else {for (int i = 0; i < instancecount ; i++) {instanceID = i;// 内部计数器(在每次迭代中前进)glDrawArrays(mode, first, count);}instanceID = 0;
}

使用实例化时,顶点着色器可以访问内置变量 gl_InstanceID,这是一个整数,指向当前正在处理对象的第几个实例。

为了使用实例化来重复我们以前的翻滚立方体示例,需要将之前在display()中的循环内实现的构建不同模型矩阵的计算移动到顶点着色器中,这样可充分利用GPU并行计算能力。由于 GLSL 不提供平移或旋转函数,并且我们无法从着色器内部调用 GLM,因此我们需要使用“✠OpenGL-3-数学基础”中的工具函数。

实例化让我们可以极大地扩展对象的副本数量; 在这个例子中, 即使对于很普通的 GPU,实现 100000 个立方体的动画仍然是可行的。
对代码中一些常量的修改,是为了将大量立方体进一步分散开。

顶点着色器代码:

#version 430
layout(location = 0) in vec3 position;
uniform mat4 v_matrix;// C++代码中传递过来
uniform mat4 proj_matrix;// C++代码中传递过来
uniform float tf; // 用于动画和放置立方体的时间因子
out vec4 varyingColor;mat4 buildRotateX(float rad);// 矩阵变换工具函数的声明
mat4 buildRotateY(float rad);
mat4 buildRotateZ(float rad);
mat4 buildTranslate(float x, float y, float z);void main() {float i = gl_InstanceID + tf;// 取值基于时间因子,但对每个立方体都不同float a = sin(203.0 * i / 8000.0) * 403.0;// 这些是用来平移的x、y、z分量float b = sin(301.0 * i / 4001.0) * 401.0;float c = sin(400.0 * i / 6003.0) * 405.0;// 构建旋转和平移矩阵,将会应用于当前立方体的模型矩阵mat4 localRotX = buildRotateX(1000 * i);mat4 localRotY = buildRotateY(1000 * i);mat4 localRotZ = buildRotateZ(1000 * i);mat4 localTrans = buildTranslate(a, b, c);// 构建模型矩阵,然后是模型-视图矩阵mat4 newM_matrix = localTrans * localRotX * localRotY * localRotZ;mat4 mv_matrix = v_matrix * newM_matrix;gl_Position = proj_matrix * mv_matrix * vec4(position, 1.0);varyingColor = vec4(position, 1.0) * 0.5 + vec4(0.5, 0.5, 0.5, 0.5);
}
// 构建矩阵的工具函数
mat4 buildTranslate(float x, float y, float z) {mat4 trans = mat4(1.0, 0.0, 0.0, 0.0,0.0, 1.0, 0.0, 0.0,0.0, 0.0, 1.0, 0.0,x, y, z, 1.0);return trans;
}
......

C++/OpenGL应用程序的display()函数中:
将camera放置到cameraZ = 420.0f;处,以可以观看到更多立方体。
下面代码使用实例化重复生成十万个翻滚立方体:

...
// 构建(和变换)mMat的计算被移动到顶点着色器中去了
// 在C++应用程序中不再需要构建MV矩阵
glUniformMatrix4fv(vLoc, 1, GL_FALSE, glm::value_ptr(vMat));// 着色器需要视图矩阵的统一变量
timeFactor = ((float)currentTime);// 获得着色器需要的时间因子信息
tfLoc = glGetUniformLocation(renderingProgram, "tf");
glUniform1f(tfLoc, timeFactor);
...
glDrawArraysInstanced(GL_TRIANGLES, 0, 36, 100000);

运行效果如下图:

在同一个场景中渲染多个不同模型
#include "Utils.h"
using namespace std;#define numVAOs 1
#define numVBOs 2float cameraX, cameraY, cameraZ;// 相机位置
float cubeLocX, cubeLocY, cubeLocZ;// 立方体位置
float pyrLocX, pyrLocY, pyrLocZ;// 金字塔位置
GLuint renderingProgram;
GLuint vao[numVAOs];
GLuint vbo[numVBOs];// variable allocation for display
GLuint mvLoc, projLoc;
int width, height;
float aspect;
glm::mat4 pMat, vMat, mMat, mvMat;void setupVertices(void) {float vertexPositions[108] ={ -1.0f,  1.0f, -1.0f, -1.0f, -1.0f, -1.0f, 1.0f, -1.0f, -1.0f,1.0f, -1.0f, -1.0f, 1.0f,  1.0f, -1.0f, -1.0f,  1.0f, -1.0f,1.0f, -1.0f, -1.0f, 1.0f, -1.0f,  1.0f, 1.0f,  1.0f, -1.0f,1.0f, -1.0f,  1.0f, 1.0f,  1.0f,  1.0f, 1.0f,  1.0f, -1.0f,1.0f, -1.0f,  1.0f, -1.0f, -1.0f,  1.0f, 1.0f,  1.0f,  1.0f,-1.0f, -1.0f,  1.0f, -1.0f,  1.0f,  1.0f, 1.0f,  1.0f,  1.0f,-1.0f, -1.0f,  1.0f, -1.0f, -1.0f, -1.0f, -1.0f,  1.0f,  1.0f,-1.0f, -1.0f, -1.0f, -1.0f,  1.0f, -1.0f, -1.0f,  1.0f,  1.0f,-1.0f, -1.0f,  1.0f,  1.0f, -1.0f,  1.0f,  1.0f, -1.0f, -1.0f,1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f,  1.0f,-1.0f,  1.0f, -1.0f, 1.0f,  1.0f, -1.0f, 1.0f,  1.0f,  1.0f,1.0f,  1.0f,  1.0f, -1.0f,  1.0f,  1.0f, -1.0f,  1.0f, -1.0f};float pyramidPositions[54] ={ -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 1.0f, 0.0f,      //front1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -1.0f, 0.0f, 1.0f, 0.0f,    //right1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, 0.0f, 1.0f, 0.0f,  //back-1.0f, -1.0f, -1.0f, -1.0f, -1.0f, 1.0f, 0.0f, 1.0f, 0.0f,  //left-1.0f, -1.0f, -1.0f, 1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, //底面—左前一半1.0f, -1.0f, 1.0f, -1.0f, -1.0f, -1.0f, 1.0f, -1.0f, -1.0f  //底面—右后一半};glGenVertexArrays(1, vao);// 需要至少1个VAOglBindVertexArray(vao[0]);glGenBuffers(numVBOs, vbo);// 这里需要2个VBOglBindBuffer(GL_ARRAY_BUFFER, vbo[0]);glBufferData(GL_ARRAY_BUFFER, sizeof(vertexPositions), vertexPositions, GL_STATIC_DRAW);// OpenGL基于“状态机”:绑定到一个目标时,该目标的前一个绑定将自动中断.// 下面代码绑定了vbo[1],立即自动中断vbo[0],之后再使用vbo[1]则需要再次绑定!glBindBuffer(GL_ARRAY_BUFFER, vbo[1]);glBufferData(GL_ARRAY_BUFFER, sizeof(pyramidPositions), pyramidPositions, GL_STATIC_DRAW);
}void init(GLFWwindow* window) {renderingProgram = Utils::createShaderProgram("vertShader.glsl", "fragShader.glsl");glfwGetFramebufferSize(window, &width, &height);aspect = (float)width / (float)height;pMat = glm::perspective(1.0472f, aspect, 0.1f, 1000.0f);cameraX = 0.0f; cameraY = 0.0f; cameraZ = 8.0f;cubeLocX = 0.0f; cubeLocY = -2.0f; cubeLocZ = 0.0f;pyrLocX = 2.0f; pyrLocY = 2.0f; pyrLocZ = 0.0f;setupVertices();
}void display(GLFWwindow* window, double currentTime) {glClear(GL_DEPTH_BUFFER_BIT);glUseProgram(renderingProgram);mvLoc = glGetUniformLocation(renderingProgram, "mv_matrix");projLoc = glGetUniformLocation(renderingProgram, "proj_matrix");// 只计算一次视图矩阵,用于2个模型vMat = glm::translate(glm::mat4(1.0f), glm::vec3(-cameraX, -cameraY, -cameraZ));// 绘制立方体(使用第1个缓冲区vbo[0])mMat = glm::translate(glm::mat4(1.0f), glm::vec3(cubeLocX, cubeLocY, cubeLocZ));mvMat = vMat * mMat;glUniformMatrix4fv(mvLoc, 1, GL_FALSE, glm::value_ptr(mvMat));glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(pMat));glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);glEnableVertexAttribArray(0);glEnable(GL_DEPTH_TEST);glDepthFunc(GL_LEQUAL);glDrawArrays(GL_TRIANGLES, 0, 36);// 注:OpenGL是基于“状态机”,所以很多方法得在第二个模型上再去调用一遍!// 绘制金字塔(使用第2个缓冲区vbo[1])mMat = glm::translate(glm::mat4(1.0f), glm::vec3(pyrLocX, pyrLocY, pyrLocZ));mvMat = vMat * mMat;glUniformMatrix4fv(mvLoc, 1, GL_FALSE, glm::value_ptr(mvMat));glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(pMat));glBindBuffer(GL_ARRAY_BUFFER, vbo[1]);glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);glEnableVertexAttribArray(0);glEnable(GL_DEPTH_TEST);glDepthFunc(GL_LEQUAL);glDrawArrays(GL_TRIANGLES, 0, 18);
}

注,虽然init()中已经调用了:

glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);
glBindBuffer(GL_ARRAY_BUFFER, vbo[1]);

但在display()中仍要调用,因为对于glBindBuffer()函数,当一个缓冲区对象绑定到一个目标时,该目标的前一个绑定将自动中断。如果去掉display()中的glBindBuffer()函数,就会导致只有第2个缓冲区有效,第1个缓冲区中断了。所以结果是用了两次vbo[1],绘制出两个金字塔模型,如下图:

正确代码下运行结果如下图:

glDrawArrays()是如何识别用哪个缓冲区数据绘图的?

这就要看离glDrawArrays()最近的代码:

glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(0);

我们知道:
每个缓冲区需要有在顶点着色器中声明的相应的顶点属性变量。
当glDrawArrays()被执行时,缓冲区中的数据开始流动,从缓冲区的开头开始,按顺序流过顶点着色器,顶点着色器对每个顶点执行一次。
OpenGL是基于状态机的。
所以离glDrawArrays()最近的代码——绑定了#1缓冲区、启用了标识号#0的顶点属性。所以肯定是利用#1缓冲区中的顶点数据绘制的,绘制的是立方体。
再往下,有代码:

glBindBuffer(GL_ARRAY_BUFFER, vbo[1]);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(0);

离glDrawArrays()最近的代码——绑定了#2缓冲区、启用了标识号#0的顶点属性。所以肯定是利用#2缓冲区中的顶点数据绘制的,绘制的是金字塔。

OpenGL是基于状态机的。所以很多代码在绘制不同模型时都要重新写一遍。如本例中,glUniformMatrix4fv()、glBindBuffer()、glVertexAttribPointer()、glEnableVertexAttribArray()、glEnable()、glDepthFunc()。

OpenGL状态机

状态机有限状态自动机的简称,是现实事物运行规则抽象而成的一个数学模型。

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

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

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

现实事物是有不同状态的,例如一个自动门,就有 open 和 closed 两种状态。我们通常所说的状态机是有限状态机,也就是被描述的事物的状态的数量是有限个,例如自动门的状态就是两个:open 和 closed 。
根据自动门的运行规则,我们可以抽象出下面这么一个图:

自动门有两个状态:open 和 closed。closed 状态下,如果读取开门信号,那么状态就会切换为 open。open 状态下如果读取关门信号,状态就会切换为 closed。

状态机的全称是有限状态自动机,自动两个字也是包含重要含义的。给定一个状态机,同时给定它的当前状态以及输入,那么输出状态是可以明确的运算出来的。例如对于自动门,给定初始状态 closed ,给定输入“开门”,那么下一个状态就是 open。

【状态机】的4大概念:
➊State:状态。一个状态机至少要包含两个状态。对于自动门,有 open 和 closed 两个状态。
➋Event:事件。事件就是执行某个操作的触发条件或者口令。对于自动门,“按下开门按钮”就是一个事件。
➌Action:动作。事件发生以后要执行动作。例如事件是“按开门按钮”,动作是“开门”。编程的时候,一个Action一般就对应一个函数。
➍Transition:变换。也就是从一个状态变化为另一个状态。例如“开门过程”就是一个变换。

矩阵堆栈

我们通常希望通过组装较小的简单模型来构建复杂的模型。例如,可以通过分别绘制头部、身体、腿部和手臂来创建“机器人”的模型,这当中每个部件都是一个单独的模型。以这种方式构建的对象通常称为分层模型。构建分层模型的棘手部分是跟踪所有模型-视图矩阵并确保它们完美协调——否则机器人可能会散成几块!

我们可以使用矩阵堆栈轻松地完成此操作。顾名思义,矩阵堆栈是一堆变换矩阵。

我们将通过以下方法使用 C++堆栈类:

  • push():在堆栈最[顶部]创建一个新的条目。
  • pop():移除(并返回)最[顶部]的矩阵。
  • top():返回堆栈最[顶部]矩阵的引用。

直接对堆栈顶部的矩阵应用变换:

  • <stack>.top() *= rotate(构建旋转矩阵的参数)。
  • <stack>.top() *= scale(构建缩放矩阵的参数)。
  • <stack>.top() *= translate(构建平移矩阵的参数)。

“*=”运算符在ma4中被重载,因此它可以用于连接矩阵。因此,我们通常将它用于向矩阵堆栈顶部的矩阵添加平移、旋转等。


现在,我们不再通过创建 mat4 的实例来构建变换,而是使用 push()命令在堆栈顶部创建新的矩阵。然后再根据需要将期望的变换应用于堆栈顶部的新创建的矩阵。推入堆栈的第一个矩阵通常是视图矩阵。它上面的矩阵是复杂程度越来越高的模型-视图矩阵;也就是说,它们应用了越来越多的模型变换。这些变换既可以直接应用,也可以先结合其他矩阵。

在我们的行星系统示例中:太阳自转;地球绕太阳公转的同时进行自转;月球绕地球公转的同时进行自转,并且还要同地球一起绕太阳公转。

计算月球在太空中的实际路径可能很复杂。然而,如果我们能够组合代表两条简单圆形路径的变换——月球围绕地球旋转的路径和地球围绕太阳旋转的路径——我们就能避免直接计算月球的轨迹。

理论分析:在栈中位于视图矩阵正上方的矩阵将是太阳的 MV 矩阵,在它之上的矩阵将是地球的 MV 矩阵——由太阳的 MV 矩阵的副本和应用于其之上的地球模型矩阵变换组成。也就是说,地球的 MV 矩阵是通过将地球的变换结合到太阳的变换中而建立的。同样,月球的 MV 矩阵位于地球的 MV 矩阵之上——通过将月球的模型矩阵变换应用于紧邻其下方的地球的 MV 矩阵来构建。

Psun = vMat ● tMatsun ● rMatsunMsun
太阳同时进行:自转(在YZ平面上绕X轴旋转)(tMatsun等于单位矩阵,不起作用)
Pearth = vMat ● tMatsun ● tMatearth ● rMatearthMearth
地球同时进行:自转(在XZ平面上绕Y轴旋转)、绕太阳公转(在XZ平面上以半径4绕Y轴平移)
Pmoon = vMat ● tMatsun ● tMatearth ● tMatmoon ● rMatmoon ● sMatmoonMmoon
月球同时进行:缩放(固定等比例缩小25%)、自转(在XY平面上绕Z轴旋转)、绕地球公转(在YZ平面上以半径2绕X轴平移)、绕太阳公转(同地球)

OpenGL 有一个内置的矩阵堆栈,但它早已被弃用。但是, C++标准模板库(STL)有一个名为“stack”的类,通过使用它构建 mat4 的堆栈,它可以相对简单直接地当作矩阵堆栈使用。

基本方法如下:
父对象:相对处于栈底的对象;子对象:相对处于栈顶的对象。
(1)声明我们的堆栈,给它起名为“mvStack”。
(2)当相对于父对象创建新对象时,调用“mvStack.push(mvStack.top())”。
(3)给新对象应用所需的变换(将变换矩阵乘以它)。
(4)完成对象或子对象的绘制后,调用“mvStack.pop()”从矩阵堆栈顶部移除其模型-视图矩阵。

整个栈操作流程如下图:(理解:处理这类[行星系统问题]要用“栈”的原因!

先看下最终的效果:(其中:金字塔(太阳),立方体(地球),小立方体(月球))

直接上代码:
我们取cameraX = 0.0f; cameraY = 0.0f; cameraZ = 12.0f; 并且所绘制的立方体与金字塔初始位置是以(0, 0, 0)为中心的,所以对模型本身的旋转变换即是模型的自转(物体空间)而不是绕世界坐标系的某条轴旋转。

stack<glm::mat4> mvStack;
void display(GLFWwindow* window, double currentTime) {// 配置背景、深度缓冲区、渲染程序,以及和原来一样的投影矩阵...// 将视图矩阵推入堆栈vMat = glm::translate(glm::mat4(1.0f), glm::vec3(-cameraX, -cameraY, -cameraZ));mvStack.push(vMat);// ------------- 太阳 -------------mvStack.push(mvStack.top());mvStack.top() *= glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f, 0.0f));// 太阳位置(实际没有平移,在此是作演示)mvStack.push(mvStack.top());mvStack.top *= glm::rotate(glm::mat4(1.0f), (float)currentTime, glm::vec3(1.0f, 0.0f, 0.0f));// 太阳自转(绕X轴)// 栈:{vMat}->{vMat|vMat}->{vMat|vMat*sunTMat}//  ->{vMat|vMat*sunTMat|vMat*sunTMat}//  ->{vMat|vMat*sunTMat|【vMat*sunTMat*sunRMat】}glUniformMatrix4fv(mvLoc, 1, GL_FALSE, glm::value_ptr(mvStack.top()));// 太阳MV矩阵关联至统一变量glBindBuffer(GL_ARRAY_BUFFER, vbo[1]);glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);glEnableVertexAttribArray(0);glEnable(GL_DEPTH_TEST);glEnable(GL_LEQUAL);glDrawArrays(GL_TRIANGLES, 0, 18);// 绘制太阳mvStack.pop();// 从栈中移除太阳的轴旋转【vMat*sunTMat*sunRMat】✖✖✖// ------------- 地球 -------------mvStack.push(mvStack.top());// 平移量:X轴=4sin(t),Y轴=0,Z轴=4cos(t)。显然这是一个以4为半径在XZ平面绕原点(0,0,0)的圆形公转轨道。mvStack.top() *= glm::translate(glm::mat4(1.0f), glm::vec3(sin((float)currentTime) * 4.0, 0.0f, cos((float)currentTime)*4.0));mvStack.push(mvStack.top());mvStack.top() *= glm::rotate(glm::mat4(1.0f), (float)currentTime, glm::vec3(0.0, 1.0, 0.0));// 地球自转(绕Y轴)// 栈:{vMat|vMat*sunTMat|vMat*sunTMat}->{vMat|vMat*sunTMat|vMat*sunTMat*earthTMat}//  ->{vMat|vMat*sunTMat|vMat*sunTMat*earthTMat|vMat*sunTMat*earthTMat}//  ->{vMat|vMat*sunTMat|vMat*sunTMat*earthTMat|【vMat*sunTMat*earthTMat*earthRMat】}glUniformMatrix4fv(mvLoc, 1, GL_FALSE, glm::value_ptr(mvStack.top()));// 地球MV矩阵关联至统一变量glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);glEnableVertexAttribArray(0);glDrawArrays(GL_TRIANGLES, 0, 36);// 绘制地球mvStack.pop();// 从栈中移除行星的轴旋转【(vMat*sunTMat*earthTMat*earthRMat)】✖✖✖// ------------- 月球 -------------mvStack.push(mvStack.top());// 平移量:X轴=0,Y轴=2sin(t),Z轴=2cos(t)。显然这是一个以2为半径在YZ平面绕原点(0,0,0)的圆形公转轨道。mvStack.top() *= glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, sin((float)currentTime) * 2.0, cos((float)currentTime)*2.0));mvStack.top() *= glm::rotate(glm::mat4(1.0f), (float)currentTime, glm::vec3(0.0, 0.0, 1.0));// 月球自转(绕Z轴)mvStack.top() *= glm::scale(glm::mat4(1.0f), glm::vec3(0.25f, 0.25f, 0.25f));// 让月球小一些// 栈:{vMat|vMat*sunTMat|vMat*sunTMat*earthTMat}//  ->{vMat|vMat*sunTMat|vMat*sunTMat*earthTMat|vMat*sunTMat*earthTMat}//  ->{vMat|vMat*sunTMat|vMat*sunTMat*earthTMat|【vMat*sunTMat*earthTMat*moonTMat*moonRMat*moonSMat】}glUniformMatrix4fv(mvLoc, 1, GL_FALSE, glm::value_ptr(mvStack.top()));// 月球MV矩阵关联至统一变量glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);glEnableVertexAttribArray(0);glDrawArrays(GL_TRIANGLES, 0, 36);// 绘制月球// 清空堆栈while (!mvStack.empty())mvStack.pop();
}

我们打印一下对太阳的平移变换矩阵:glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f, 0.0f)); 结果如下:

[0][0]=1 [0][1]=0 [0][2]=0 [0][3]=0
[1][0]=0 [1][1]=1 [1][2]=0 [1][3]=0
[2][0]=0 [2][1]=0 [2][2]=1 [2][3]=0
[3][0]=0 [3][1]=0 [3][2]=0 [3][3]=1

显然是个单位矩阵,不可能有平移变换作用(其实通过glm::translate的第2个参数向量:vec(0,0,0) 就可以大致推断)。即太阳位置并不产生平移,在上述示例中的对太阳的平移变换矩阵,只是为了示例原理的完整性而故意加上的。因为其实太阳也是围绕银河系公转的,这里没涉及而已;如果涉及太阳的公转,那这个对太阳的平移变换矩阵就有真实作用了,即通过平移变换矩阵完成太阳对银河系的公转。

问题1:glm::rotate()模型是自转还是绕[世界坐标系]的轴旋转?


为了进行最简单的测试:
1.将立方体中心点从世界坐标系原点(0,0,0)沿Z轴负方向平移2个单位:
tMat = glm::translate(glm::mat4(1.0f), glm::vec3(0, 0, -2));
2.构造绕X轴的旋转矩阵:
rMat = glm::rotate(glm::mat4(1.0f), (float)currentTime, glm::vec3(1.0, 0.0, 0.0));
3.display(window, currentTime) { Pcube = vMat * rMat * tMat * Mcube; }
观察动画效果:

可以看到,立方体显然不是自转,它是绕世界坐标系的X轴在旋转。
答:glm::rotate()模型是绕[世界坐标系]的轴旋转。

问题2:如何实现模型的自转?

在“问题1”中的第1点是将立方体从原点(0,0,0)平移了位置,假设不平移的话,显然是自转,当然它肯定也是绕世界坐标系的轴旋转的。在“问题1”中是先平移,再绕世界坐标系的轴旋转。那么,如果我们先不平移,先在原点(0,0,0)旋转(即自转),然后再进行平移呢?理论上,那就应该是自转的同时再平移。让我们验证一下:
本立方体默认就是以(0,0,0)为中心点构建的,所以在此就不用进行平移到原点的操作。
rMat = glm::rotate(glm::mat4(1.0f), (float)currentTime, glm::vec3(1.0, 0.0, 0.0));
tMat = glm::translate(glm::mat4(1.0f), glm::vec3(0, 0, sin((float)currentTime) * 4.0));
display(window, currentTime) { Pcube = vMat * tMat * rMat * Mcube; }

可以看到,立方体在自转,同时在世界空间沿Z轴方向来回平移运动。
由此推论➱实现自转得满足两条件:
①让模型中心点处于世界坐标原点(如果原不在世界原点, 平移变换之);
②在满足①的情况下先对模型进行旋转变换然后才是其他动画操作(先于旋转变换进行其他变换可以, 前提是不影响条件①)。

在上面“行星系统示例”中,1.所绘制的立方体与金字塔初始位置是以(0, 0, 0)为中心的;2.无论是太阳、地球还是月球,在矩阵乘法式子里,旋转矩阵因子总是在最末尾,即最先对模型进行旋转变换。这两点,就刚好满足上面得出的推论,所以太阳、地球和月球都很好地实现了自转。

还有一点要提的就是,月球在世界原点(0,0,0)最先进行的是缩放而非旋转变换,但并未影响上面推论中的条件①,紧接着对月球进行的就是旋转变换,满足条件②,所以能正常完成月球的自转逻辑。


应对“Z冲突”伪影

下图就是一个“Z冲突”的示例:

在渲染多个对象时, OpenGL 使用 Z缓冲区算法(Z-buffer algorithm)来进行隐藏面消除。通常情况下,通过选择最接近相机的相应片段的颜色作为像素的颜色,这种方法解决了哪些物体的曲面可见并呈现到屏幕,而哪些曲面位于其他物体后面因此不应该被渲染。

有时候场景中的两个物体表面重叠并位于重合的平面中,这使得 Z 缓冲区算法难以确定应该渲染两个表面中的哪一个(因为两者都不“最接近”摄像机)。发生这种情况时,浮点舍入误差可能会导致渲染表面的某些部分使用另一个对象的颜色。这种不自然的伪影被称为 Z冲突(Z-fighting)深度冲突(Depth-fighting),因为这种效果是渲染的片段在Z缓冲区中相互对应的像素条目上“冲突斗争”的结果。

创建地形或阴影时经常会出现这种情况。在这种情况下,有时 Z 冲突是可以预知的,并且校正它的常用方法是:稍微移动一个物体,使得表面不再是共面的。

Z 冲突还可能是由于深度缓冲器中的值的精度有限。对于由 Z 缓冲器算法处理的每个像素,其深度信息的精度受深度缓冲器中可存储的位数限制。用于构建透视矩阵的近剪裁平面和远剪裁平面之间的范围越大,具有相似(但不相等)的实际深度的两个对象的点在深度缓冲区中的数值表示越可能相同。因此,程序员可以选择适当的近、远剪裁平面值来最小化两个平面之间的距离,同时仍然确保场景必需的所有对象都位于视锥内。

同样重要的是要理解,由于透视变换的影响,改变近剪裁平面值可能比对远剪裁平面进行等效变化对于 Z 冲突伪影具有更大的影响。因此,建议避免选择太靠近眼睛的近剪裁平面。

应对“Z冲突”伪影,总结:
产生原因:
➀表面重叠——Z缓冲区算法难以确定应该渲染两个表面中的哪一个。
➁浮点舍入误差——导致渲染表面的某些部分使用另一个对象的颜色。
解决方案:
➊稍微移动一个物体,使得表面不再共面。
➋选择适当的近、远剪裁平面值来最小化两个平面之间的距离。
    注:同时仍要确保场景必需的所有对象都位于视锥内。
➌建议避免选择太靠近眼睛的近剪裁平面。
    注:由于透视变换的影响,改变近剪裁平面值可能比对远剪裁平面进行等效变化对于Z冲突伪影具有更大的影响。

图元的其他选项

OpenGL 支持许多图元类型——到目前为止我们已经看到了两个: GL_TRIANGLES 和GL_POINTS。事实上,还有好几个其他的选择。 OpenGL 支持的所有可用图元类型都属于三角形、线、点或者补丁的类别。

以下是一个完整的清单:

性能优先的编程方法&背面剔除

随着 3D 场景的复杂性增加,我们将越来越关注性能。我们已经看到一些例子,我们为了速度做出一些编程决策——例如当我们使用实例化时, 以及当我们将昂贵的计算转移到着色器时。

❶尽量减少动态内存空间分配
将 display()函数的开销保持在最低限度的一个重要方法是避免任何需要内存分配的步骤。因此,明显要避免的事情的例子包括:

  • 实例化对象;
  • 声明变量。

我们在调用display()函数之前,就已经为要用到的每个变量分配了空间。声明或实例化几乎不出现在 display() 函数中。如:

// 将要用在display()函数中的变量空间,这样它们就不必在渲染过程中分配
GLuint mvLoc, projLoc;
int width, height;
float aspect;
glm::mat4 pMat, vMat, mMat, mvMat;

使用 C++堆栈类,每次“推”操作都会导致动态内存分配。在 Java 中,JOML 库提供了一个与OpenGL 一起使用的 MatrixStack 类, 它允许为矩阵堆栈预先分配空间。所以在C++中也尽量预先分配空间,而避免在渲染过程中分配。

还有其他更微妙的例子。例如,将数据从一种类型转换为另一种类型的函数调用在某些情况下可能会实例化并返回新转换的数据。因此,理解从 display()调用的任何库函数的行为非常重要。数学库 GLM 并没有专门针对速度优化设计。这导致一些操作可能引起动态内存分配。如果可能的话,我们会尽量使用直接在已经分配了空间的变量上操作的 GLM 函数。鼓励程序员在性能至关重要时探索替代方法。

❷预先计算透视矩阵
可以减少 display()函数开销的另一个优化是将透视矩阵的计算移动到 init() 函数中。
虽然通常并不需要重新计算透视矩阵,但是如果运行应用程序的用户调整窗口大小(例如通过拖动窗口角大小调整手柄),则重新计算就是必要的。
幸运的是, GLFW 可以配置在调整窗口大小时自动回调指定的函数。在调用 init()之前,我们将以下内容添加到 main():
glfwSetWindowSizeCallback(window, window_reshape_callback);

void init(GLFWwindow* window) {. . .// 和之前版本一样,再加上以下三行代码:(从display()中去掉这三行)glfwGetFramebufferSize(window, &width, &height);aspect = (float)width / (float)height;pMat = glm::perspective(1.0472f, aspect, 0.1f, 1000.0f); // 1.0472 radians = 60 degrees
}
void window_reshape_callback(GLFWwindow* window, int newWidth, int newHeight) {aspect = (float)newWidth / (float)newHeight; // 回调提供的新的宽度、高度glViewport(0, 0, newWidth, newHeight); // 设置和帧缓冲区相关的屏幕区域pMat = glm::perspective(1.0472f, aspect, 0.1f, 1000.0f);
}
int main(void) {...glfwSetWindowSizeCallback(window, window_reshape_callback);...
}

❸背面剔除
提高渲染效率的另一种方法是利用 OpenGL 的背面剔除能力。

当 3D 模型完全“闭合”时,意味着内部永远不可见(例如对于立方体和金字塔),那么外表面的那些与观察者背离呈一定角度的部分将始终被同一模型的其他部分遮挡。也就是说,那些背离观察者的三角形不可能被看到(无论如何它们都会在隐藏面消除的过程中被覆盖),因此没有理由光栅化或渲染它们

可以使用命令glEnable(GL_CULL_FACE)要求 OpenGL 识别并“剔除”(不渲染)背向的三角形。我们还可以使用 glDisable(GL_CULL_FACE)禁用背面剔除。默认情况下,背面剔除是[关闭的],因此如果您希望 OpenGL 剔除背向三角形,必须手动启用它。

[启用背面剔除]时,[默认情况]下,只有三角形朝前时才会[被渲染]。此外,默认情况下,三角形这样才被视为【面向前方】:三角形的 3 个顶点从 OpenGL 摄像机中查看是以逆时针顺序排列(基于它们在缓冲区中定义的顺序)。顶点沿顺时针方向排列的三角形(从OpenGL摄像机中看)是朝后的,不会被渲染

这种逆时针方向定义的“前向”有时被称为缠绕顺序, 可以使用函数调用 glFrontFace(GL_CCW)(CCW:CounterClockWise)显式设置逆时针(默认)为正向, 或 glFrontFace(GL_CW)设置顺时针为正向。类似地,也可以显式设置是否渲染正向或背向的三角形。实际上,为了这个目的,我们指定哪些不被渲染——即哪些被“剔除”。我们可以通过调用glCullFace(GL_BACK)指定面向背面的三角形被剔除(尽管这是不必要的,因为它是默认的)。或者,我们可以通过分别用 GL_FRONT 或 GL_FRONT_AND_BACK 替换参数GL_BACK 来指定剔除前向三角形,甚至剔除所有三角形。

3D 模型通常被设计成外表面由相同缠绕顺序的三角形构成——最常见的是逆时针——因此如果启用剔除, 则默认情况下模型的外部面向相机的表面部分会被渲染。

注意, 在 GL_TRIANGLE_STRIP 的情况下, 每个三角形的缠绕顺序不停地互换,如下图:

OpenGL通过在构建每个连续三角形时“翻转” 顶点序列来补偿这一点, 如下所示:
0-1-2, 然后 2-1-3、2-3-4、 4-3-5、 4-5-6 等。

背面剔除通过确保 OpenGL 不花时间光栅化和渲染从不被看到的表面来提高性能。

在实践中,大多数 3D 模型通常是“闭合的”,因此习惯上会常规地启用背面剔除。

void display(GLFWwindow* window, double currentTime) {...glEnable(GL_CULL_FACE);// 默认是关闭的,必须手动启用// 绘制立方体...glEnable(GL_DEPTH_TEST);glDepthFunc(GL_LEQUAL);glFrontFace(GL_CW);// 将立方体顶点缠绕顺序设置为顺时针方向glDrawArrays(GL_TRIANGLES, 0, 36);// 绘制金字塔...glEnable(GL_DEPTH_TEST);glDepthFunc(GL_LEQUAL);glFrontFace(GL_CCW);// 金字塔顶点缠绕顺序为逆时针方向glDrawArrays(GL_TRIANGLES, 0, 18);

使用背面剔除时,正确设置缠绕顺序非常重要。

[效率]不是进行背面剔除的唯一原因!
在后面的章节中,我们将看到其他用途,例如我们想要查看 3D 模型内部或使用透明度时的情况。

补充说明

在 OpenGL/GLSL 中,有许多其他功能和结构可用于管理和利用数据,我们在本章中仅涉及了很浅层的一部分。
例如,我们没有描述统一块,这是一种类似于C中的struct的用于统一变量的机制。甚至可以设置统一块从缓冲区接收数据。另一个强大的机制是着色器存储块,它本质上是一个着色器可以写入的缓冲区。

✠OpenGL-4-管理3D图形数据相关推荐

  1. OpenGL与3D开发-绘制3D图形

    绘制3D图形 文章目录 绘制3D图形 1.立方体 2.圆形 3.球形 3.1.球面顶点坐标计算 使用OpenGL ES绘制3D图形的方法与绘制2D图形的步骤大致相同,只是绘制3D图形需要定义更多的顶点 ...

  2. vb 打开sdf数据_DWG、DGN、SDF、DWF!Civil 3D图形数据共享的几种方法

    来源:欧特克BIM俱乐部 版权归原作者所有 我们可以使用多种方法来共享 Autodesk Civil 3D 图形数据.下表列出了每种方法所使用的功能: 使用代理图形 代理图形保存最后一次查看的 Aut ...

  3. 图解opengl 3D 图形绘制实例

    VC6 下载 http://blog.csdn.net/bcbobo21cn/article/details/44200205 opengl环境配置 http://blog.csdn.net/bcbo ...

  4. OpenGL与3D图形世界

    一.OpenGL与3D图形世界 1.1.OpenGL使人们进入三维图形世界 我们生活在一个充满三维物体的三维世界中,为了使计算机能精确地再现这些物体,我们必须能在三维空间描绘这些物体.我们又生活在一个 ...

  5. android opengl ppt,Android开发和 与实践课件第12章利用OpenGL实现3D图形.ppt

    Android开发和 与实践课件第12章利用OpenGL实现3D图形.ppt half, -half, half, half, half, half, // 上面 -half, half, half, ...

  6. Qt 数据可视化之3D图形

    前段时间在Qt事例中看到了3D图形,就参考案例,做了一个相似的demon. 不多说了,先看做的两个效果图吧. 这里主要讲解第一个,第二个的代码会打包上传.感兴趣可以下载. 工程文件: QT += da ...

  7. 3D图形图像处理软件HOOPS介绍及下载

    HOOPS 3D Application Framework(以下简称HOOPS)是建立在OpenGL.Direct3D等图形编程接口之上的更高级别的应用程序框架.不仅为您提供强大的图形功能,还内嵌了 ...

  8. Learn OpenGL(一)图形渲染管线(Pipeline)

    在OpenGL中任何事物都在3D空间中, 但是屏幕和窗口是一个2D像素阵列, 所以OpenGL的大部分工作都是关于如何把3D坐标转变为适应你屏幕的2D像素. 3D坐标转为2D坐标的处理过程是由Open ...

  9. 编写你的应用程序(三)、3D图形

    原文链接:https://developer.chrome.com/native-client/devguide/coding/3D-graphics 注意:已针对ChromeOS以外的平台公布了此处 ...

最新文章

  1. python进程间通信的秘密
  2. 一、nginx基本模块以及模块配置
  3. ConEmu状态栏的设置介绍
  4. i-doIT 0.9.9-7发布 CMDB配置管理数据库
  5. php转译html,使用php转义输出HTML到JavaScript
  6. python位运算符_NumPy按位运算符解析和实例详解
  7. nginx绑定IP的坑
  8. 苹果又发布一个机器学习框架,帮自家生态里的开发者降低AI门槛
  9. 建筑施工承插型盘扣式钢管支架安全技术规程_承插型盘扣式钢管支架施工技术交底(视频教程)...
  10. 排序算法之——快速排序分析
  11. Docker教程:docker远程repository和自建本地registry
  12. Servlet的原理和基础使用
  13. html改变鼠标指针形状代码,改变鼠标指针形状_js改变鼠标形状与样式的方法
  14. Value of type java.lang.String cannot be conver...
  15. BUUCTF——MISC(流量分析)
  16. 基于云原生的私有化交付PAAS平台
  17. netfilter_queue
  18. speedtree中文对照ppt_SpeedTree树木建模入门知识整理
  19. 程序员口中的demo是什么意思_怎样让5分钟的曲子不重样播放450天?程序员:用马尔可夫链...
  20. Java IOS客户端上传多张图片到服务端

热门文章

  1. 基于FPGA的数字视频信号处理器设计(中)
  2. OM1、OM2、OM3、OM4、OM5 和 OS1、OS2 光纤
  3. LIMS实验室信息管理系统、LIMS系统、样本中心、样本库、报告中心、探针、试剂盒、fish检测、药物管理、基因检测、容器管理、病理诊断、核酸质检、血浆分离、核酸提取、混样处理、文库质检、Axure
  4. 嵌入式Linux开发-在6818开发板上显示图案
  5. 钜泉光电ATT7053C芯片STM32 SPI驱动
  6. 第四章 常见 Android 文件格式(二)(classes.dex)
  7. Linux 解压方式总结
  8. 大学计算机专业分流考试,【SHU·分流季】历时四周,只为你!(附智科专业分流信息)...
  9. [A-VI] 电路基本元件II
  10. 燃料电池系统HIL测试解决方案