WHCSRL 技术网

游戏引擎学习记录(2)- 渲染(OpenGL)- 变换操作

前言

今天做的是第二课变换。在场景的渲染中,变换操作是非常常见的。当游玩FPS游戏第一人称中,当你角色向前移动,你周围的场景都在向后移动。包括周围的树木、木箱等等道具。道具需要被合理的缩放以及旋转等操作。同样的,视角上的投影方法也会影响游戏的效果。本文会首先阐述并明确以下几个3D数学知识:矩阵、齐次坐标、三种常见的基本变换,以及在使用引擎及OpenGL中遇到的一些问题以及函数参数问题。当明确这些知识后,会更容易理解渲染中要做的工作,同时自己也复习以下相关知识。

几个3D数学的知识

矩阵(Matrix)

矩阵是在图形学中用来表示坐标以及各种变换操作最基本的数学集合。

由 m × n 个数aij排成的m行n列的数表称为m行n列的矩阵,简称m × n矩阵。记作:

 通常情况下在点坐标的表示时,会用矩阵保存点坐标。在这里先不做赘述了。

齐次坐标

这是最近面试被问到最多的问题之一:为什么在图形学中选择使用齐次坐标进行表示图像矩阵?这里大概总结为以下两点:

1. 常见的三种变换操作:旋转、平移和缩放,平移在表示的时候通常是以矩阵加减法的形式来计算,但旋转和缩放通常是用矩阵乘除法。这样的话在计算上以及代码书写上会增加计算量并在书写上造成麻烦。引入齐次坐标的目的主要是合并矩阵运算中的乘法和加法,它提供了用矩阵运算把二维、三维甚至高维空间中的一个点集从一个坐标系变换到另一个坐标系的有效方法。因此在矩阵变换上,三种矩阵最后连乘即可得到的图像矩阵。

2. 能够简化透视投影的计算,这部分我们后面再谈。

三种变换操作

三种常见的变换操作:平移、旋转、缩放

齐次坐标的问题上我们讨论到了用三种矩阵相乘的方法来表示模型矩阵。那有一个问题出现了,三种矩阵的乘法顺序有要求吗?从经验来看,通常情况下,先缩放,再旋转,最后平移,矩阵相乘的方式是合理的,具体原因请移步:

opengl 教程(11) 平移/旋转/缩放 - 迈克老狼2012 - 博客园,讲的很详细

空间变换

OpenGL的空间变换包括以下三种:

Model matrix 模型矩阵。进行物体坐标系到世界坐标系的转换。控制了物体的平移、旋转、缩放。在3D建模软件中为模型坐标,导入游戏后使用model martrix进行大小、位置、角度的相关设置。
View matrix 观察矩阵。将世界坐标系变换到观察者坐标系,通过一些平移、旋转的组合来移动整个场景(而不是去移动摄像机,摄像机是一个虚拟的概念,事实上代码中并没有摄像机camera, 而是用view martix来表示摄像机,然后把view matrix附加到每一个物体,来模拟相关的摄像机操作),用来模拟一个摄像机。
projection matrix 投影矩阵。将观察者坐标系转换到裁剪坐标系。将3D坐标投影到2D屏幕上,裁剪空间外的顶点会被裁掉,投影矩阵指定了坐标的范围。

空间变换的流程如下图所示:

 

摘自:收集的图形学面试问题小结资料_莫寒技术人生-CSDN博客_图形学面试

正交投影以及透视投影

这两种投影是投影矩阵Projection Matrix下的重点。因为三维物体要投影到二维才能显示。从视角坐标系转换到剪裁坐标系。将3D坐标投影到2D屏幕上,裁剪空间外的顶点会被裁掉,投影矩阵指定了坐标的范围。

 

在OpenGL中,如果想对模型进行操作,就要对这个模型的状态(当前的矩阵)乘上这个操作对应的一个矩阵. 如果乘以变换矩阵(平移, 缩放, 旋转), 那相乘之后, 模型的位置被变换; 如果乘以投影矩阵(将3D物体投影到2D平面), 相乘后, 模型的投影方式被设置; 如果乘以纹理矩阵(), 模型的纹理方式被设置. 而用来指定乘以什么类型的矩阵, 就是glMatriMode(GLenum mode); glMatrixMode有3种模式: GL_PROJECTION 投影, GL_MODELVIEW 模型视图, GL_TEXTURE 纹理

所以,在操作投影矩阵以前,需要调用函数:
glMatrixMode(GL_PROJECTION); //将当前矩阵指定为投影矩阵
然后把矩阵设为单位矩阵:
glLoadIdentity();

实现部分会做对这一部分算法设计的解释。

实现

在今天的代码里,我们会着重实现模型的透视投影、正交投影以及三种常见的变换操作,基于上一课实现三角形的基础上。

首先我们按照之前的思路先创建本课Renderer.h和Renderer.cpp

  1. #pragma once
  2. #include "../nclgl/OGLRenderer.h"
  3. class Renderer :public OGLRenderer {
  4. public:
  5. Renderer(Window& parent);
  6. virtual ~Renderer(void);
  7. virtual void RenderScene();
  8. //今天要实现的函数:正交投影 透视投影 缩放、旋转、平移
  9. void SwitchToPerspective();
  10. void SwitchToOrthographic();
  11. inline void SetScale(float s) { scale = s; }
  12. inline void SetRotation(float r) { rotation = r; }
  13. inline void SetPosition(Vector3 p) { position = p; }
  14. protected:
  15. Mesh* triangle;
  16. Shader* matrixShader;
  17. float scale;
  18. float rotation;
  19. Vector3 position;
  20. };

因为后续部分我们要实现一个按键操作手动选择正交/透视投影以及按键操作进行平移缩放和旋转,所以先把这部分列在本课头文件中。同时通过Shader类新建一个他的对象matrixShader,scale rotation和position用于保存数值

接下来看一下实现正交以及透视投影的函数SwitchToPerspective()和SwitchToOrthographic()

  1. void Renderer::SwitchToOrthographic()
  2. {
  3. projMatrix = Matrix4::Orthographic(-1.0f, 10000.0f, width / 2.0f, -width / 2.0f, height / 2.0f, -height / 2.0f);
  4. }
  5. void Renderer::SwitchToPerspective()
  6. {
  7. projMatrix = Matrix4::Perspective(1.0f, 10000.0f, (float)width / (float)height, 45.0f);
  8. }

在这里使用了Matrix4类中的两个函数,在透视投影和正交投影间切换投影矩阵,透视矩阵需要4个参数(近,远,横纵比,水平视野),正交投影需要6个参数(轴,方向,后,前,左,上,下)

因为nclgl是对opengl的重新封装和重写,所以在Matrix4类中找到了下边两个函数的定义:

  1. Matrix4 Matrix4::Perspective(float znear, float zfar, float aspect, float fov) {
  2. Matrix4 m;
  3. const float h = 1.0f / tan(fov*PI_OVER_360);
  4. float neg_depth = znear-zfar;
  5. m.values[0] = h / aspect;
  6. m.values[5] = h;
  7. m.values[10] = (zfar + znear)/neg_depth;
  8. m.values[11] = -1.0f;
  9. m.values[14] = 2.0f*(znear*zfar)/neg_depth;
  10. m.values[15] = 0.0f;
  11. return m;
  12. }
  13. //http://www.opengl.org/sdk/docs/man/xhtml/glOrtho.xml
  14. Matrix4 Matrix4::Orthographic(float znear, float zfar,float right, float left, float top, float bottom) {
  15. Matrix4 m;
  16. m.values[0] = 2.0f / (right-left);
  17. m.values[5] = 2.0f / (top-bottom);
  18. m.values[10] = -2.0f / (zfar-znear);
  19. m.values[12] = -(right+left)/(right-left);
  20. m.values[13] = -(top+bottom)/(top-bottom);
  21. m.values[14] = -(zfar+znear)/(zfar-znear);
  22. m.values[15] = 1.0f;
  23. return m;
  24. }

这里笔者一开始是带着疑问的:为什么正交投影需要6个参数而透视投影只需要4个参数?在查阅了OpenGL的实现方法后,发现OpenGL对每个透视方法都有两种不同的定义:

正交投影

void glOrtho(GLdouble left, GLdouble right, GLdouble bottom, GLdouble top, GLdouble zNear, GLdouble zFar);


void gluOrtho2D(GLdouble left, GLdouble right, GLdouble bottom, GLdouble top);


透视投影

void glFrustum(GLdouble left, GLdouble right, GLdouble bottom, GLdouble top, GLdouble zNear, GLdouble zFar);


void gluPerspective(GLdouble fovy, GLdouble aspect, GLdouble zNear, GLdouble zFar);


可以发现正交投影在3D下确实是6个参数,但透视投影有6参/4参两种。对于透视投影的两种不同的实现方法,笔者理解如下:

void glFrustum(GLdouble left, GLdouble Right, GLdouble bottom, GLdouble top, GLdouble near, GLdouble far);
创建一个透视型的视景体。其操作是创建一个透视投影的矩阵,并且用这个矩阵乘以当前矩阵。这个函数的参数只定义近裁剪平面的左下角点和右上角点的三维空间坐标,即(left,bottom,-near)和(right,top,-near);最后一个参数far是远裁剪平面的离视点的距离值,其左下角点和右上角点空间坐标由函数根据透视投影原理自动生成。near和far表示离视点的远近,它们总为正值(near/far 必须>0)。

left,right       表示近平面左右两边相对于垂直平面的位置

bottom,top   表示近平面顶和底相对于水平面的位置

zNear和zFar是表示视点到远近投影平面的距离

gluPerspective(GLdouble fovy,GLdouble aspect,GLdouble zNear,GLdouble zFar);

创建一个对称的透视型视景体,但它的参数定义于前面的不同,如图。其操作是创建一个对称的透视投影矩阵,并且用这个矩阵乘以当前矩阵。参数fovy定义视野在Y-Z平面的角度,范围是[0.0, 180.0];参数aspect是投影平面宽度与高度的比率;参数Near和Far分别是近远裁剪面到视点(沿Z负轴)的距离,它们总为正值。
以上两个函数缺省时,视点都在原点,视线沿Z轴指向负方向。

 理解:glFrustum 和 gluPerspective都是透视矩阵的操作,后者是对前者的简单封装,后者不再需要上下左右的位置,需要fovy和aspect,fovy是视角大小,aspect是视景体的宽高比(观点有误请指出,不胜感激)

那么为什么在正交投影的参数设置上我们把z值设成了负数?这里老师给出了他的想法:因为我们通常用正交投影绘制屏幕上的文本信息,在这个时候我们通常把他的z值设为0,如果将投影设为负数可以保证这些文本信息和HUD永远被显示。(笔者仍有疑惑)

 下一步我们实现RenderScene函数:

  1. void Renderer::RenderScene()
  2. {
  3. glClear(GL_COLOR_BUFFER_BIT);
  4. BindShader(matrixShader);
  5. glUniformMatrix4fv(glGetUniformLocation(matrixShader->GetProgram(), "projMatrix"), 1, false, projMatrix.values);
  6. glUniformMatrix4fv(glGetUniformLocation(matrixShader->GetProgram(), "viewMatrix"), 1, false, viewMatrix.values);
  7. for (int i = 0; i < 3; ++i)
  8. {
  9. Vector3 tempPos = position;
  10. tempPos.z += (i * 500.0f);
  11. tempPos.x -= (i * 100.0f);
  12. tempPos.y -= (i * 100.0f);
  13. modelMatrix = Matrix4::Translation(tempPos) * Matrix4::Rotation(rotation, Vector3(0, 1, 0)) * Matrix4::Scale(Vector3(scale, scale, scale));
  14. glUniformMatrix4fv(glGetUniformLocation(matrixShader->GetProgram(),"modelMatrix"), 1, false, modelMatrix.values);
  15. triangle->Draw();
  16. }
  17. }

这里我们首先清空屏幕颜色,绑定shader,之后我们通过glUniformMatrix4fv函数将对应的空间矩阵传递给shader,之后通过for循环绘制三个三角形,并计算好他们的模型矩阵。这里问题又出现了,前文提到对于模型矩阵应该先缩放、在旋转、最后平移,为什么我们的矩阵是反向乘的?

先看乘号的重载问题:

 这里发现矩阵乘法的重载是遵循OpenGL的顺序,也就是先乘(从右向左),看重载实现:

  1. //Multiplies 'this' matrix by matrix 'a'. Performs the multiplication in 'OpenGL' order (ie, backwards)
  2. inline Matrix4 operator*(const Matrix4 &a) const{
  3. Matrix4 out;
  4. //Students! You should be able to think up a really easy way of speeding this up...
  5. for(unsigned int r = 0; r < 4; ++r) {
  6. for(unsigned int c = 0; c < 4; ++c) {
  7. out.values[c + (r*4)] = 0.0f;
  8. for(unsigned int i = 0; i < 4; ++i) {
  9. out.values[c + (r*4)] += this->values[c+(i*4)] * a.values[(r*4)+i];
  10. }
  11. }
  12. }
  13. return out;
  14. }

老师这里还非常细心的提示了乘法顺序是backwards QAQ

具体先乘和后乘的原理请移步:浅谈矩阵变换——Matrix_走向远方-CSDN博客_矩阵变换

之后我们编写新的矩阵顶点shader,命名为MatrixVertex.glsl:

  1. #version 330 core
  2. uniform mat4 modelMatrix;
  3. uniform mat4 viewMatrix;
  4. uniform mat4 projMatrix;
  5. in vec3 position;
  6. in vec4 colour;
  7. out Vertex{
  8. vec4 colour;
  9. } OUT;
  10. void main(void){
  11. mat4 mvp = projMatrix*viewMatrix*modelMatrix;
  12. gl_Position = mvp*vec4(position,1.0);
  13. OUT.colour = colour;
  14. }

这里我们再次发现了一个问题,前面提到空间变换的方法是先通过模型矩阵变换到世界空间,再通过视角矩阵变换到视角空间,最后通过投影矩阵变换到剪裁空间,为什么mat4 mvp = projMatrix*viewMatrix*modelMatrix;是倒着乘的?

这里涉及到OpenGL的列优先存储(列序存储)概念,首先,shader中的三个空间变换的量是定义为uniform mat4的,他们是通过我们在编译器中的glUniformMatrix4fv函数传递给shader,这个函数的定义如下:

oid glUniformMatrix4fv (GLint location, GLsizei count, GLboolean transpose, const GLfloat * value)

通过一致变量(uniform修饰的变量)引用将一致变量值传入渲染管线。

location : uniform的位置。
count : 需要加载数据的数组元素的数量或者需要修改的矩阵的数量。
transpose : 指明矩阵是列优先(column major)矩阵(GL_FALSE)还是行优先(row major)矩阵(GL_TRUE)。
value : 指向由count个元素的数组的指针。
摘自:glUniformMatrix4fv_android开发笔记-CSDN博客_gluniformmatrix4fv

其中第三个变量transpose需要我们明确指出到底是以列优先还是行优先的方法传入shader,我们这里选择了列优先的方法,也就是说,我们在编译器里保存的矩阵是行优先,比如:

        m00 m01

        m10 m11

在列优先的存储下,他被转置了,所以传入shader的矩阵其实是:

        m00 m10

        m01 m11

因此,在使用glUniformMatrix4fv函数时以及后续的乘法顺序上,一定要注意我们到底是什么顺序保存的矩阵!!这里我们选择倒着乘,也就是正常的空间变换顺序!

Renderer.cpp完整如下:

  1. #include "Renderer.h"
  2. Renderer::Renderer(Window& parent) : OGLRenderer(parent)
  3. {
  4. triangle = Mesh::GenerateTriangle();
  5. //颜色的片段着色器不动 需要写一个新的矩阵顶点shader
  6. matrixShader = new Shader("MatrixVertex.glsl", "colourFragment.glsl");
  7. if (!matrixShader->LoadSuccess())
  8. {
  9. return;
  10. }
  11. init = true;
  12. SwitchToOrthographic();//切换到正交投影
  13. }
  14. Renderer::~Renderer(void)
  15. {
  16. delete triangle;
  17. delete matrixShader;
  18. }
  19. void Renderer::SwitchToOrthographic()
  20. {
  21. projMatrix = Matrix4::Orthographic(-1.0f, 10000.0f, width / 2.0f, -width / 2.0f, height / 2.0f, -height / 2.0f);
  22. }
  23. void Renderer::SwitchToPerspective()
  24. {
  25. projMatrix = Matrix4::Perspective(1.0f, 10000.0f, (float)width / (float)height, 45.0f);
  26. }
  27. void Renderer::RenderScene()
  28. {
  29. glClear(GL_COLOR_BUFFER_BIT);
  30. BindShader(matrixShader);
  31. glUniformMatrix4fv(glGetUniformLocation(matrixShader->GetProgram(), "projMatrix"), 1, false, projMatrix.values);
  32. glUniformMatrix4fv(glGetUniformLocation(matrixShader->GetProgram(), "viewMatrix"), 1, false, viewMatrix.values);
  33. for (int i = 0; i < 3; ++i)
  34. {
  35. Vector3 tempPos = position;
  36. tempPos.z += (i * 500.0f);
  37. tempPos.x -= (i * 100.0f);
  38. tempPos.y -= (i * 100.0f);
  39. modelMatrix = Matrix4::Translation(tempPos) * Matrix4::Rotation(rotation, Vector3(0, 1, 0)) * Matrix4::Scale(Vector3(scale, scale, scale));
  40. glUniformMatrix4fv(glGetUniformLocation(matrixShader->GetProgram(),"modelMatrix"), 1, false, modelMatrix.values);
  41. triangle->Draw();
  42. }
  43. }

主函数如下,其实只比第一课添加了对应的按键以及操作:

  1. #include "../nclgl/window.h"
  2. #include "Renderer.h"
  3. int main() {
  4. Window w("Vertex Transformation!", 800, 600, false);
  5. if (!w.HasInitialised()) {
  6. return -1;
  7. }
  8. Renderer renderer(w);
  9. if (!renderer.HasInitialised()) {
  10. return -1;
  11. }
  12. float scale = 100.0f;
  13. float rotation = 0.0f;
  14. Vector3 position(0, 0, -1500.0f);
  15. while (w.UpdateWindow() && !Window::GetKeyboard()->KeyDown(KEYBOARD_ESCAPE)) {
  16. if (Window::GetKeyboard()->KeyDown(KEYBOARD_1))
  17. renderer.SwitchToOrthographic();
  18. if (Window::GetKeyboard()->KeyDown(KEYBOARD_2))
  19. renderer.SwitchToPerspective();
  20. if (Window::GetKeyboard()->KeyDown(KEYBOARD_PLUS)) ++scale;
  21. if (Window::GetKeyboard()->KeyDown(KEYBOARD_MINUS)) --scale;
  22. if (Window::GetKeyboard()->KeyDown(KEYBOARD_LEFT)) ++rotation;
  23. if (Window::GetKeyboard()->KeyDown(KEYBOARD_RIGHT)) --rotation;
  24. if (Window::GetKeyboard()->KeyDown(KEYBOARD_K))
  25. position.y -= 1.0f;
  26. if (Window::GetKeyboard()->KeyDown(KEYBOARD_I))
  27. position.y += 1.0f;
  28. if (Window::GetKeyboard()->KeyDown(KEYBOARD_J))
  29. position.x -= 1.0f;
  30. if (Window::GetKeyboard()->KeyDown(KEYBOARD_L))
  31. position.x += 1.0f;
  32. if (Window::GetKeyboard()->KeyDown(KEYBOARD_O))
  33. position.z -= 1.0f;
  34. if (Window::GetKeyboard()->KeyDown(KEYBOARD_P))
  35. position.z += 1.0f;
  36. renderer.SetRotation(rotation);
  37. renderer.SetScale(scale);
  38. renderer.SetPosition(position);
  39. renderer.RenderScene();
  40. renderer.SwapBuffers();
  41. }
  42. return 0;
  43. }

注意,我们第一次在这里使用了SwapBuffers函数,因为我们要实现一个按键之后动态的操作,因此我们有两个缓冲区进行轮转读写,防止画布撕裂,底层代码这里暂时不讲。

结果

正交投影:

透视投影:

 

 自此,我们已经完成空间变换的操作,并且可以通过键盘按键控制他的平移及旋转。

推荐阅读