OpenGL ES 2.0 笔记 #7:空间变换

从 3D 场景到屏幕 2D 图像,经过了一系列空间/坐标变换才得以完成:

  • Vertex Shader - 取决于 3D 建模和渲染的实际工作流程,先后进行 3 次变换。3D 模型顶点坐标由本地模型坐标转换到 Clip Space
    • Model Transform(模型变换)- 3D 模型放置到 3D 场景(即:世界)
    • View Transform(视图变换)- 摄像机以特定姿态(观察角度/朝向)放置到 3D 世界的特定位置;以摄像机为参照,将 3D 世界转换到摄像机空间
    • Projection Transform(投影变换)- 由投影方式和摄像机本身的参数确定。投影方式分为正交投影和透视投影 2 种。在透视投影的情况下,确定坐标变换的摄像机参数包括:水平和竖直方向的视角(Field of View)、近景面和远景面的距离(z)
  • Rasterizer -
    • Perspective Division(透视除)- 完成 Clip Space 坐标的 normalize,即由 (x, y, z, w) -> (x/w, y/w, z/w, 1)。经过 Perspective Division 后的空间称为 NDC (Normalized Device Coordinates)
    • Viewport Transform(视口变换)- 将 NDC 映射到屏幕 Viewport 内。分解为 translate 和 scale,无 rotate

摄像机空间定义如下,特别注意朝向为 -z:

Vertex shader 完全编程实现,Rasterizer 硬件固定实现,程序只能修改部分参数,例如设置 Viewport。

OpenGL 将坐标表示为列矢量(Column Vector,即 4x1 矩阵),而不是行矢量(Row Vector,即 1x4 矩阵);因此,变换矩阵作用于坐标要使用"左乘",即

vbnet 复制代码
v' = M⋅v

若经过多次变换,变换矩阵依次为 M1, M2, M3..., Mn,则乘顺序为

vbnet 复制代码
v' = Mn⋅...⋅M3⋅M2⋅M1⋅v

Vertex Shader 使用右手坐标(RHS),而 Rasterizer 使用左手坐标(LHS),因此,在 Vertex Shader 的最后一步要进行 RHS 到 LHS 的转换,最简单的实现是将 z 取负。

cglm 是 C 实现的用于空间变换运算的库,定义了与 OpenGL/GLSL 相兼容的 vector, matrix 类型及操作等。cglm 可以不需编译链接,仅仅包含头文件即可。头文件中的函数全部定义为 inline

用 cglm 进行 3D 变换,获得变换矩阵:

C 复制代码
#include <cglm/cglm.h>

...

mat4 m = GLM_MAT4_IDENTITY_INIT;
{
    vec3 t = {.5, -.5, 0};
    glm_translate(m, t);

    float a = (float)SDL_GetTicks() / 1000;
    vec3 z = {0, 0, 1};
    glm_rotate(m, a, z);

    vec3 s = {.5, .5, .5};
    glm_scale(m, s);
}

注意由于如前所述变换矩阵的"左乘"原则,代码中的变换顺序与实际刚好相反。3D 模型原本位于原点,首先进行 scale 缩小到一半,然后以 z 轴为中心旋转一定角度,这里的旋转角度不固定,取决于时间戳。最后向左下角方向平移 0.5 的距离。

以上代码在 render() 函数内反复运行,因此呈现出转动的效果。

转换矩阵以 uniform 供 vertex shader 访问:

C++ 复制代码
uniform mat4 m_trans;

void main()
{
    gl_Position = m_trans * a_pos;

    ...
}

程序中将变换矩阵的值复制给 uniform

C 复制代码
GLuint prog = ...
GLint trans_loc = glGetUniformLocation(prog, "m_trans");
glUniformMatrix4fv(trans_loc, 1, GL_FALSE, (GLfloat *)m);

cglm 的 matrix 实现为二维数组 float[4][4],直接将首地址提交给 OpenGL。

程序运行如下。完整代码在 gitlab.com/sihokk/lear...

上一例程代码中演示了对 3D 模型的分解变换,即 scale, rotate, translate。在实际的 3D 应用中,通常按照 MVP(Model, View, Projection)的顺序进行变换。以游戏为例,游戏角色的移动属于 Model 变换,玩家的视角变化属于 View 变换。下面代码进行简单的 MVP 变换:

C 复制代码
mat4 m_model = GLM_MAT4_IDENTITY_INIT;
{
    vec3 x = {1, 0, 0};
    glm_rotate(m_model, glm_rad(-55), x);
}

mat4 m_view = GLM_MAT4_IDENTITY_INIT;
{
    vec3 v = {0, 0, -3};
    glm_translate(m_view, v);
}

mat4 m_proj;
{
    glm_perspective(glm_rad(45), render_state.screen_width / (float)render_state.screen_height, 1, 100, m_proj);
}

Model 变换将 3D 模型绕 x 轴旋转一定角度;View 变换将摄像机后移(+z 方向)3,对于 3D 模型等同于向 -z 方向平移 3。Projection 变换进行透视投影,提供摄像机参数,cglm 函数 glm_perspective() 计算出变换矩阵。

将所得 3 个变换矩阵提交给 shader:

C 复制代码
GLuint prog = ...
GLint loc_model = glGetUniformLocation(prog, "m_model");
GLint loc_view = glGetUniformLocation(prog, "m_view");
GLint loc_proj = glGetUniformLocation(prog, "m_proj");
glUniformMatrix4fv(loc_model, 1, GL_FALSE, (GLfloat *)m_model);
glUniformMatrix4fv(loc_view, 1, GL_FALSE, (GLfloat *)m_view);
glUniformMatrix4fv(loc_proj, 1, GL_FALSE, (GLfloat *)m_proj);

在 vertex shader 中用变换矩阵乘顶点坐标,进行空间转换:

C++ 复制代码
attribute vec4 a_pos;

uniform mat4 m_model;
uniform mat4 m_view;
uniform mat4 m_proj;

void main()
{
    gl_Position = m_proj * m_view * m_model * a_pos;
    ...
}

注意到:左乘,且 MVP 顺序为从右到左。

程序执行结果如下图。完整代码见 gitlab.com/sihokk/lear...

到上面的例程为止,我们使用的 3D 模型都是 2D 平面形体。下面将绘制一个真正的 3D 模型,立方体。立方体 6 个面,每个面 2 个三角形,因此立方体总共需要 36 个顶点,虽然其中大部分位置重合:

C 复制代码
const GLfloat vertices[] = {
    -0.5f, -0.5f, -0.5f, 0.0f, 0.0f,
    0.5f, -0.5f, -0.5f, 1.0f, 0.0f,
    0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
    ...
};

GLint attr_pos = glGetAttribLocation(prog, "a_pos");
GLint attr_tex = glGetAttribLocation(prog, "a_tex");

const GLsizei stride = 5 * sizeof(GLfloat);

glEnableVertexAttribArray(attr_pos);
glVertexAttribPointer(attr_pos, 3, GL_FLOAT, GL_FALSE, stride, 0);

glEnableVertexAttribArray(attr_tex);
glVertexAttribPointer(attr_tex, 2, GL_FLOAT, GL_FALSE, stride, (void *)(3 * sizeof(GLfloat)));

每个顶点包含 x/y/z 位置坐标和 texture 坐标 s/t,共 5 项数据。位置坐标和 texture 坐标分别绑定到 shader attribute 变量。

运行程序,将会发现立方体显示异常:

这是因为没有启用 OpenGL 深度测试(Depth Test)。若未启用深度测试,可能导致渲染时将原本"背面"的面绘制到前台,就像上面那样。OpenGL 默认不启用深度测试。首先要在初始化时启用深度测试,其次在每次渲染一帧前都需要清除深度缓冲,如下:

C 复制代码
static void render_init()
{
    glEnable(GL_DEPTH_TEST);
    ...
}

static void render()
{
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    ...
}

另外,在创建 SDL2 OpenGL context 时,须确保支持深度缓冲:

C 复制代码
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 8);

启用 depth test 后,立方体就能被正确绘制出来:

完整代码在 gitlab.com/sihokk/lear...

留意到有个细节。在上面示例中,将 MVP 矩阵单独提交给 shader,矩阵乘实际由 shader 执行。Vertex shader 对每一个顶点执行一次,有多少个顶点,就要进行多少次重复的矩阵乘。这是不必要的。可以在程序中进行 MVP 变换矩阵乘,将最终结果矩阵提交给 shader:

C 复制代码
mat4 m = GLM_MAT4_IDENTITY_INIT;

// Rotate
{
    mat4 m1 = GLM_MAT4_IDENTITY_INIT;
    ...
    glm_rotate(m1, ...);

    glm_mat4_mul(m1, m, m);
}

// Translate
{
    mat4 m1 = GLM_MAT4_IDENTITY_INIT;
    ...
    glm_translate(m1, ...);

    glm_mat4_mul(m1, m, m);
}

// Projection
{
    mat4 m1;
    glm_perspective(..., m1);

    glm_mat4_mul(m1, m, m);
}

GLuint prog = ...
GLint loc = glGetUniformLocation(prog, "m_trans");
glUniformMatrix4fv(loc, 1, GL_FALSE, (GLfloat *)m);

注意矩阵乘 glm_mat4_mul() 的参数的顺序,以及 MVP 的顺序!

Vertex shader 直接将变换矩阵应用到顶点坐标:

C++ 复制代码
uniform mat4 m_trans;

void main()
{
    gl_Position = m_trans * a_pos;
    ...
}

这次用一个 for 循环绘制 10 个立方体。实际是同一个 3D 模型,经过不同的空间变换绘制 10 次,例如不同的选择角度、放置在不同位置,等等。程序运行效果如下:

程序代码在 gitlab.com/sihokk/lear...

相关推荐
刘好念20 小时前
[OpenGL]使用 Compute Shader 实现矩阵点乘
c++·计算机图形学·opengl·glsl
阳光开朗_大男孩儿2 天前
为什么glfwWindowHint设置的属性,glfwCreateWindow可以直接使用?
前端·数据库·opengl
刘好念4 天前
[OpenGL]使用TransformFeedback实现粒子效果
c++·计算机图形学·opengl
吃豆腐长肉6 天前
着色器 (三)
opengl·着色器
吃豆腐长肉6 天前
opengl 着色器 (四)最终章收尾
opengl·着色器
德林恩宝7 天前
WebGPU、WebGL 和 OpenGL/Vulkan对比分析
web·webgl·opengl·webgpu
zaizai100711 天前
LearnOpenGL学习(高级OpenGL -> 高级GLSL,几何着色器,实例化)
opengl
刘好念11 天前
[OpenGL] Transform feedback 介绍以及使用示例
c++·计算机图形学·opengl
爱看书的小沐11 天前
【小沐学GIS】基于C++绘制三维数字地球Earth(OpenGL、glfw、glut、QT)第三期
c++·qt·opengl·earth·osm·三维地球·数字地球
闲暇部落14 天前
OpenGL ES详解——多个纹理实现混叠显示
opengl·纹理叠加