在三角形和纹理贴图示例中,顶点使用的是归一化设备坐标,在该坐标系下,顶点的每个轴的取值为-1到1,超出范围的顶点不可见。
基于归一化设备坐标的物体的形状随着设备的大小变换而变化,这里产生的第一个问题是,本来要画一个正方形,顶点为(-0.5,0.5)、(0.5,0.5)(0.5,-0.5)(-0.5,-0.5),由于显示设备的宽与高不一致,结果显示出来的是一个长方形。即使针对某个设备定义好了一组顶点,显示出来刚好是正方形,可以在不一样尺寸显示的却是长方形。
基于归一化坐标系定义物体顶点遇到的另一个问题是,无法让物体动起来,如果确实要动起来,需要为每一帧画面定义新的顶点数据,这个工作量非常大,一般不会这么操作。
为了更好的绘制出想要的物体,一般在物体本身的坐标系定义顶点数据以确定物体的形状,再经过一系列的变换映射到归一化设备坐标系,在设备上才会显示期望的效果。通过改变变换的参数,顶点不用改动,物体显示效果也不一样,因此通过不断改变变换参数可以让物体动起来。
变换是从一种坐标系映射到另一种坐标系的过程,接下来介绍变换过程中涉及到的坐标系。
坐标系
- Local Space(局部空间),局部空间基于局部坐标系。局部坐标系是指相对于某个物体或者某个参考点而言的坐标系。它是通过将物体的中心或者某个特定点作为原点,以物体自身的方向作为坐标轴来定义的。局部坐标系通常用于描述物体的内部结构或者变换。
- World Space(世界空间),世界空间基于世界坐标系。世界坐标系是指全局的坐标系,它是以整个场景或者世界空间作为参考的坐标系。世界坐标系通常是固定的,用于描述场景中各个物体之间的相对位置关系。
- View Space(观察空间),观察空间针对摄像机而已,把摄像机比作眼睛,在世界坐标系中不同的位置朝着不同的方向观察到的景象是不一样的,需要使用一个独立的坐标系来表示物体在观察者视图中的位置,这就是观察坐标系。
- Clip Space (裁剪空间), 裁剪空间的主要作用是减少处理的计算量,提高图形处理的效率。通过裁剪空间,可以只对感兴趣的图像区域进行处理,而不用对整个图像进行操作。这样可以节省计算资源。裁剪空间使用归一化设备坐标系(Normalized Device Coordinates ),每个轴的范围为-1到1,超出范围的图形不显示。
- Screen Space(屏幕空间),屏幕空间主要针对具体的显示设备,解决的是图形中的每个像素在屏幕中的位置。不同设备屏幕的宽、高、原点、及轴方向会有差异,经过渲染后的图形需要映射到屏幕坐标才能正确显示出来。
变换过程
了解坐标系后,接下来介绍物体在各个坐标之间的变换过程,以三角形为例。
首先在局部坐标系定义三角形的顶点数据,在局部坐标系中根据期望的形状来定义三角形的顶点数据,数据的取值范围不再局限在-1~1之间。
模型矩阵
接下来把三角形从局部坐标系映射到世界坐标系,这一步映射的目的是确定三角形在世界坐标系中的位置、占据的空间及朝向,从局部坐标系映射到世界坐标系通过模型矩阵(Model Matrix)进行变换,模型矩阵可由位移矩阵、缩放矩阵、旋转矩阵进行结合:比如只是简单把三角形移动到世界坐标的(1,0,0),模块矩阵使用位移矩阵即可;如何三角形移动到(1,0,0)后再缩小一半,模型矩阵为位移矩阵乘缩放矩阵;如果三角形移动到(1,0,0)后再缩小一半最后再绕z轴旋转90度,模型矩阵为位移矩阵乘缩放矩阵再乘旋转矩阵。
值得注意的是矩阵乘法是由先后顺序的,顺序不一样,一般情况下结果也不一样。比如先位移再缩放和先缩放再位移结果是不一样的。
观察矩阵
三角形的顶点坐标映射到世界坐标系后,观察者在不同的位置超着不同的方向看到的结果是不一样的,因此需要从世界坐标系映射到观察坐标系。这一步映射使用视图矩阵(View Matrix)进行变换。观察矩阵需要通过3个向量才能确定:观察者的位置,观察中心点,穹顶向量,其中穹顶向量的作用是观察者头部的朝向。
投影矩阵
有了观察矩阵,观察者的位置、观察的中心点及朝向都已确定,此时观察者可以看到正前方的物体,但是这里还有视野的问题,观察者的眼睛睁大和眯成一条缝,看到的结果也是不一样的,睁大了可以看到整个三角形,眯成一条缝可能只看到三角形的一部分。视野在这里使用投影矩阵表示。
投影分为正交投影(orthographic projection)和透视投影(perspective projection)。
正交投影:在该投影方式下,物体的大小与距离无关。
透视投影:在该投影方式下,物体靠观察者越近,大小越大。
经过投影矩阵的映射后,得到的是NDC坐标,此时可以开始绘制了。接下来通过示例来加深对变换过程的理解。
示例
在本示例中绘制立方体,首先定义立方体的顶点数据
struct Vertex {
glm::vec3 Position;
glm::vec3 Color;
};
struct Transform {
glm::vec3 Pose;
glm::vec3 Scale;
};
constexpr glm::vec3 Red{1, 0, 0};
constexpr glm::vec3 DarkRed{0.25f, 0, 0};
constexpr glm::vec3 Green{0, 1, 0};
constexpr glm::vec3 DarkGreen{0, 0.25f, 0};
constexpr glm::vec3 Blue{0, 0, 1};
constexpr glm::vec3 DarkBlue{0, 0, 0.25f};
// Vertices for a 1x1x1 meter cube. (Left/Right, Top/Bottom, Front/Back)
constexpr glm::vec3 LBB{-0.5f, -0.5f, -0.5f};
constexpr glm::vec3 LBF{-0.5f, -0.5f, 0.5f};
constexpr glm::vec3 LTB{-0.5f, 0.5f, -0.5f};
constexpr glm::vec3 LTF{-0.5f, 0.5f, 0.5f};
constexpr glm::vec3 RBB{0.5f, -0.5f, -0.5f};
constexpr glm::vec3 RBF{0.5f, -0.5f, 0.5f};
constexpr glm::vec3 RTB{0.5f, 0.5f, -0.5f};
constexpr glm::vec3 RTF{0.5f, 0.5f, 0.5f};
#define CUBE_SIDE(V1, V2, V3, V4, V5, V6, COLOR) {V1, COLOR}, {V2, COLOR}, {V3, COLOR}, {V4, COLOR}, {V5, COLOR}, {V6, COLOR},
constexpr Vertex c_cubeVertices[] = {
CUBE_SIDE(LTB, LBF, LBB, LTB, LTF, LBF, DarkRed) // -X
CUBE_SIDE(RTB, RBB, RBF, RTB, RBF, RTF, Red) // +X
CUBE_SIDE(LBB, LBF, RBF, LBB, RBF, RBB, DarkGreen) // -Y
CUBE_SIDE(LTB, RTB, RTF, LTB, RTF, LTF, Green) // +Y
CUBE_SIDE(LBB, RBB, RTB, LBB, RTB, LTB, DarkBlue) // -Z
CUBE_SIDE(LBF, LTF, RTF, LBF, RTF, RBF, Blue) // +Z
};
c_cubeVertices表示立法体的顶点数据,由6个面组成。
CUBE_SIZE表示立法体的一个面,由3个2角形构成,需要6个顶点,外加该面的颜色
接下来定义绘制立法体的顶点索引,如下所示。
constexpr unsigned short c_cubeIndices[] = {
0, 1, 2, 3, 4, 5, // -X
6, 7, 8, 9, 10, 11, // +X
12, 13, 14, 15, 16, 17, // -Y
18, 19, 20, 21, 22, 23, // +Y
24, 25, 26, 27, 28, 29, // -Z
30, 31, 32, 33, 34, 35, // +Z
};
在这里所有的变换由着色器进行处理,着色器的定义如下。
static const char* strVs = R"_(#version 300 es
in vec3 VertexPos;
in vec3 VertexColor;
out vec3 PSVertexColor;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main() {
gl_Position = projection * view * model * vec4(VertexPos, 1.0);
PSVertexColor = VertexColor;
}
)_";
static const char* strFs = R"_(#version 300 es
in lowp vec3 PSVertexColor;
out lowp vec4 FragColor;
void main() {
FragColor = vec4(PSVertexColor, 1);
}
)_";
由于变换只针对顶点,因此 只需在顶点着色器中对进行做变换,在着色器有3个变换矩阵。
- model为模型矩阵。
- view为视图矩阵。
- projection为投影矩阵。
在顶点着色器中,模型矩阵先与顶点向量先乘,得到新的向量再与视图矩阵相乘,最后于投影矩阵相乘,因此顶点变换的另一种写法如下。
gl_Position = projection * ( view * (model * vec4(VertexPos, 1.0)));
这个写法更容易体现model、view、projection与向量相乘的顺序。
接下来把顶点数据绑定到VBO、VAO和EBO中,
auto coordsLocation = mShader->getAttribLocation("VertexPos");
auto colorLocation = mShader->getAttribLocation("VertexColor");
//init VBO VAO EBO
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(c_cubeVertices), c_cubeVertices, GL_STATIC_DRAW);
glGenBuffers(1,&EBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(c_cubeIndices), c_cubeIndices, GL_STATIC_DRAW);
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);
LOGI("TransformSample::init coordsLocation %d colorLocation %d",coordsLocation,colorLocation);
glEnableVertexAttribArray(coordsLocation);
glEnableVertexAttribArray(colorLocation);
glVertexAttribPointer(coordsLocation, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void *)0);
glVertexAttribPointer(colorLocation, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<const void*>(sizeof(glm::vec3)));
VBO、VAO已经对这部分内容做过介绍,不一样的地方是顶点属性的序号并没有在着色器通过location指定,这里通过getAttribLocation获取属性的序号。
顶点数据和着色器准备好以后,在绘制时确定变换矩阵,接下来确定要绘制的立方体,如下所示。
cubes.push_back(Transform{{-1.5,-0.5,0}, {0.5f, 0.5f, 0.5f}});//25cm
cubes.push_back(Transform{{0,-0.3,0}, {0.5f, 0.5f, 0.5f}});
cubes.push_back(Transform{{1.5,0.5,0}, {0.55f, 0.55f, 0.55f}});
这里要绘制3个立法体:第1个平移到位置(-1.5, -0.5, 0),各个轴大小都缩小到原来的0.5倍;第2个平移到(0,-0.3,0),各个轴大小同样缩小为原来的0.5倍;第3个平移到(1.5, 0.5, 0),大小缩小为原来的0.55倍。
glm::mat4 view = glm::lookAt(glm::vec3(0,0,3),
glm::vec3(0,0,0),
glm::vec3(0,1,0));
glm::mat4 projection = glm::perspective(glm::radians(45.0f), (float)mWidth / (float)mHeight, 0.1f, 1000.0f);
mShader->setMatrix4f("view", view);
mShader->setMatrix4f("projection", projection);
static float angle = 0;
int i = 0;
for(auto cube: cubes){
//pass identity matrix
glm::mat4 model(1.0f);
model = glm::translate(model,cube.Pose);
model = glm::scale(model, cube.Scale);
if(i%3==0){
model = glm::rotate(model,glm::radians(angle),glm::vec3(1,0,0));
}else if(i%3 == 1){
model = glm::rotate(model,glm::radians(angle),glm::vec3(0,1,0));
}else{
model = glm::rotate(model,glm::radians(angle),glm::vec3(0,0,1));
}
i++;
mShader->setMatrix4f("model", model);
glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_SHORT, nullptr);
}
通过lookAt可以获得视图矩阵。
glm::mat4 view = glm::lookAt(glm::vec3(0,0,3), glm::vec3(0,0,0), glm::vec3(0,1,0));
第1个参数表示观察者位置,第2个参数表示观察中心点,第3个参数表示穹顶向量。
通过perspective可获得透视投影矩阵。
glm::mat4 projection = glm::perspective(glm::radians(45.0f), (float)mWidth / (float)mHeight, 0.1f, 1000.0f);
第1个参数表示视野角度,第2个参数表示宽高比例,第3个参数表示最近距离,第4个参数表示最远距离。
对于模型矩阵,首先定义一个初始的单位矩阵,glm::mat4 model(1.0f),先进行平移变换 model = glm::translate(model,cube.Pose); 接着进行缩放变换model = glm::scale(model, cube.Scale); 最后通过glm::rotate进行旋转变换,这里分3种情况,第1个绕x轴旋转,第2个绕y轴旋转,第3个绕z轴旋转。
这些模型矩阵通过Shader类的setMatrix4f传递到着色器。着色器拿到这些矩阵后,执行变换后可得到期望的效果,示例运行效果图如下所示。
从效果图可以看到,显示的是一个立方体,通过不断改变旋转变换矩阵以达到不断旋转的动画效果。
本示例工程代码已上传到github,地址如下。
示例工程代码https://github.com/leesino/samples/tree/main/OpenGL/Transformations