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...

相关推荐
扶尔魔ocy4 天前
【qt openGL入门笔记】windows编译MinGW版本的osgearth(2.10.1)
qt·opengl·osg
扶尔魔ocy5 天前
【QT OpenGL】知识点编目及传送门
qt·opengl·osg
扶尔魔ocy17 天前
【qt openGL入门笔记】OSG实现飞机模型在网格图上飞行并进行态势标绘(四)
qt·opengl·osg
:mnong19 天前
Web 三维可视化技术对比:ParaView Web vs 本项目 wasm + Three.js
前端·webgl·opengl·opencl·科学可视化
扶尔魔ocy20 天前
【qt openGL入门笔记】OSG实现显示飞机模型飞行的点迹(二)
qt·opengl·osg
扶尔魔ocy20 天前
【qt openGL入门笔记】OSG实现飞机模型在网格图上飞行(三)
opengl·osg
扶尔魔ocy21 天前
【qt openGL入门笔记】OSG实现飞机模型在网格图上飞行
qt·opengl·osg
扶尔魔ocy25 天前
【qt openGL入门笔记】FPS相机
qt·opengl
扶尔魔ocy25 天前
qt openGL入门笔记】FPS相机和轨道相机
qt·opengl
扶尔魔ocy1 个月前
【qt openGL入门笔记】基础光照
qt·opengl