从 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 次,例如不同的选择角度、放置在不同位置,等等。程序运行效果如下: