摄像机在 3D 场景(世界坐标系)中的放置要确定 2 个要素:(1)位置;(2)姿态。位置用一个 vector 表达其坐标。姿态用 3 个 unit vector 表达:front, up, right,如下图。这 3 个 unit vector 按照右手法则相互正交,因此,只要确定其中任意 2 个,进行叉积计算即得到第三个,例如: front × up = right
摄像机(本地)空间以 right 为 x 方向,以 up 为 y 方向,因此,根据右手法则,front 为 -z 方向。
把摄像机位置和姿态作为程序状态,共 3 个 vector:
static struct
{
...
vec3 camera_pos;
vec3 camera_front;
vec3 camera_up;
} render_state = {0};
定义一个 camera_reset()
函数对摄像机状态进行初始化。在程序初始化阶段要调用该函数:
#define CAMERA_MOVE_RADIUS 3.f
...
static void camera_reset()
{
vec3 pos = {0, 0, CAMERA_MOVE_RADIUS};
glm_vec3_copy(pos, render_state.camera_pos);
vec3 orig = GLM_VEC3_ZERO_INIT;
glm_vec3_sub(orig, pos, render_state.camera_front);
glm_vec3_normalize(render_state.camera_front);
vec3 up = {0, 1, 0}; // Y
glm_vec3_copy(up, render_state.camera_up);
}
初始状态下,摄像机放在世界坐标系 Z 轴上((0, 0, 3)),朝向原点,以 Y 为 up 方向。注意用朝向目标点(原点)与摄像机位置进行 vector 减运算得到 front 方向上的 vector,经过 normalize 就得到 front。
进行 view 变换时,调用 cglm 的 glm_lookat()
生成变换矩阵。这个函数的参数,除了摄像机位置和 up vector 之外,还需要 front 方向上的任一位置坐标,而不是 front 本身。这个位置用摄像机位置与 front 进行 vector 加即可获得,如以下代码中的 target
变量:
mat4 m1;
vec3 target;
glm_vec3_add(render_state.camera_pos, render_state.camera_front, target);
glm_lookat(render_state.camera_pos, target, render_state.camera_up, m1);
在每次渲染前,更新摄像机位置,沿着以 Y 轴为中心的圆形轨迹旋转,转动角度根据系统时间戳转换得到。摄像机始终朝向原点。如下:
float a = (float)(SDL_GetTicks() / 1000.);
vec3 v1 = {
CAMERA_MOVE_RADIUS * sin(a),
0,
CAMERA_MOVE_RADIUS * cos(a),
};
glm_vec3_copy(v1, render_state.camera_pos);
glm_vec3_negate(v1) ;
glm_vec3_normalize( v1 ) ;
glm_vec3_copy( v1, render_state.camera_front) ;
这里计算 front 时换了一种算法,利用摄像机朝向原点这一特殊条件,将摄像机位置取反并 normalize 就得到 front。
完整代码见 gitlab.com/sihokk/lear…
程序中摄像机绕 Y 轴按角度正方向(逆时针)旋转,程序运行时,呈现的效果则是 3D 模型绕 Y 轴按角度负方向(顺时针方向)旋转。如下图:
键盘控制摄像机移动
预定实现目标是:摄像机姿态不变,font 固定为 -Z 方向(向“内”),up 为 Y 方向(因此 right 固定为 X 方向);键盘按键实现摄像机左右(沿 X 轴)和远近(沿 Z 轴)位置移动。定义两个位置移动函数:
static void camera_move_right(float dist)
{
render_state.camera_pos[0] += dist;
}
static void camera_move_forward(float dist)
{
render_state.camera_pos[2] -= dist;
}
接收到 SDL 键盘事件时,调用这两个函数,对摄像机位置进行更新:
#define CAMERA_MOVE_SPEED 0.01f // distance in 1 ms
...
// Key press: camera movement
if (SDL_KEYDOWN == e.type)
{
const SDL_Keycode k = e.key.keysym.sym;
if (SDLK_w == k)
{
const float dist = CAMERA_MOVE_SPEED * render_state.frame_interval;
camera_move_forward(dist);
}
else if (SDLK_s == k)
{
const float dist = CAMERA_MOVE_SPEED * render_state.frame_interval;
camera_move_forward(-dist);
}
else if (SDLK_a == k)
{
const float dist = CAMERA_MOVE_SPEED * render_state.frame_interval;
camera_move_right(-dist);
}
else if (SDLK_d == k)
{
const float dist = CAMERA_MOVE_SPEED * render_state.frame_interval;
camera_move_right(dist);
}
...
}
if (SDL_KEYUP == e.type)
{
const SDL_Keycode k = e.key.keysym.sym;
if (SDLK_PERIOD == k || SDLK_KP_PERIOD == k)
{
camera_reset();
}
...
}
按下 A, D 键时,摄像机将分别向左(-X)右(X)移动;按下 W, S,则分别向内(-Z)外(Z)移动。移动保持匀速,根据当前帧与上一帧的时间间隔计算出来。另外,按下 .(句号)键将摄像机重置到初始位置。
帧间隔由下面的函数实现,在主循环中每次渲染前调用。时间单位为 ms:
static struct
{
...
uint32_t frame_interval;
} render_state = {0};
static void calc_frame_interval()
{
static struct
{
bool valid;
uint32_t timestamp;
} last_frame = {false};
const uint32_t now = SDL_GetTicks();
if (last_frame.valid)
{
render_state.frame_interval = now - last_frame.timestamp;
last_frame.timestamp = now;
}
else
{
last_frame.valid = true;
last_frame.timestamp = now;
render_state.frame_interval = 0;
}
}
完整代码见 gitlab.com/sihokk/lear…
鼠標控制攝像機轉向
如下图,摄像机姿态可由 2 个角度确定:(1)front 与 X-Z 平面的夹角为仰角 pitch;(2)right 与 X 轴之间的夹角为偏航角 yaw。利用三角函数,front/up/right 与 pitch/yaw 之间可以相互转化:
- sin(pitch) = front.y
- sin(yaw) = - right.z
注意到,right 始终位于 X-Z 平面内。
定义 2 个功能函数,在 front/up/right 与 pitch/yaw 之间进行相互转化:
static void get_eular_angles(const float *front, const float *up, float *pitch, float *yaw)
{
float pitch1 = asinf(front[1]);
if (isnan(pitch1))
{
pitch1 = front[1] > 0 ? GLM_PI_2f : (-GLM_PI_2f);
}
*pitch = pitch1;
vec3 right;
glm_vec3_cross(front, up, right);
glm_vec3_normalize(right);
float yaw1 = asinf(-right[2]);
if (isnan(yaw1))
{
yaw1 = right[2] > 0 ? (-GLM_PI_2f) : GLM_PI_2f;
}
if (right[0] < 0)
{
yaw1 = GLM_PIf - yaw1;
}
if (yaw1 < 0)
{
yaw1 += (2 * GLM_PIf);
}
*yaw = yaw1;
}
static void set_eular_angles(float *front, float *up, float pitch, float yaw)
{
vec3 right = {
cosf(yaw),
0,
-sinf(yaw),
};
float len_p1 = cosf(pitch);
vec3 front1 = {
len_p1 * cosf(yaw + GLM_PI_2f),
sinf(pitch),
-sinf(yaw + GLM_PI_2f),
};
glm_vec3_normalize(front1);
glm_vec3_copy(front1, front);
vec3 up1;
glm_vec3_cross(right, front1, up1);
glm_vec3_normalize(up1);
glm_vec3_copy(up1, up);
}
通过鼠标事件更新摄像机的 pitch/yaw 姿态角。当鼠标在窗口上进行拖动(按下鼠标左键后移动)操作时,水平方向移动距离确定摄像机的 yaw,即摄像机左右转动;垂直方向的移动距离确定 pitch,即摄像机上下转动(俯仰)。进行鼠标操作时,摄像机位置不变。
在程序状态中增加了鼠标相关数据:
static struct
{
...
struct
{
bool active;
int32_t begin_x;
int32_t begin_y;
float32_t begin_pitch;
float32_t begin_yaw;
...
} mouse_drag;
} render_state = {0};
实现的思路是这样的。在开始拖放时,记下鼠标的位置,以及根据此时摄像机的姿态(front/up)计算出的姿态角 pitch/yaw。在拖放过程中,由鼠标当前位置与初始位置计算出移动的距离,按照一定规则将此距离转换为 pitch/yaw 的变化值,对 pitch/yaw 进行更新,之后将其转换回 front/up 状态,并进行渲染。
姿态角 pitch/yaw 只是在拖动操作的过程中便于直接进行角度计算。在 OpenGL view 变换时,仍以 font/up 参与计算(glm_lookat()
),与前面的例程保持不变。
当鼠标左键按下时,开始拖放操作,对应的处理函数如下:
static void camera_drag_begin(int32_t x, int32_t y)
{
render_state.mouse_drag.active = true;
render_state.mouse_drag.begin_x = x;
render_state.mouse_drag.begin_y = y;
...
get_eular_angles(render_state.camera_front, render_state.camera_up,
&render_state.mouse_drag.begin_pitch,
&render_state.mouse_drag.begin_yaw);
}
拖放过程中,根据鼠标移动的距离,按照一定关系转换为角度的变化量,更新 pitch 和 yaw 后,计算出摄像机 front/up。代码如下:
#define CAMERA_DRAG_FOV 90 // in degrees
static void camera_drag(int32_t x, int32_t y)
{
int32_t dx, dy;
...
dx = x - render_state.mouse_drag.begin_x;
dy = y - render_state.mouse_drag.begin_y;
float speed = CAMERA_DRAG_FOV / glm_min(render_state.screen_width, render_state.screen_height);
speed = glm_rad(speed);
float pitch = render_state.mouse_drag.begin_pitch - speed * dy;
float yaw = render_state.mouse_drag.begin_yaw - speed * dx;
set_eular_angles(render_state.camera_front, render_state.camera_up, pitch, yaw);
}
这里以窗口绘制区域较短一条边长为基准,鼠标移动该边长的距离则对应 90 度的角度变化。
相较于上一个示例程序,由于摄像机朝向发生了变化,将键盘移动摄像机位置的代码进行了修改,使用了常规的矢量运算,如下:
static void camera_move_right(float dist)
{
vec3 right;
glm_vec3_cross(render_state.camera_front, render_state.camera_up, right);
glm_normalize(right);
glm_vec3_scale(right, dist, right);
glm_vec3_add(render_state.camera_pos, right, render_state.camera_pos);
}
static void camera_move_forward(float dist)
{
vec3 front;
glm_vec3_copy(render_state.camera_front, front);
glm_vec3_scale(front, dist, front);
glm_vec3_add(render_state.camera_pos, front, render_state.camera_pos);
}
SDL2 鼠标事件处理部分的实现很简单:
while(1)
{
...
// Left mouse button down
if (SDL_MOUSEBUTTONDOWN == e.type && SDL_BUTTON_LEFT == e.button.button)
{
camera_drag_begin(e.button.x, e.button.y);
continue;
}
// Left mouse button release
if (SDL_MOUSEBUTTONUP == e.type && SDL_BUTTON_LEFT == e.button.button)
{
camera_drag_end();
continue;
}
// Mouse drag
if (SDL_MOUSEMOTION == e.type)
{
if (!render_state.mouse_drag.active)
{
continue;
}
camera_drag(e.motion.x, e.motion.y);
continue;
}
...
}
完整代码请参考 gitlab.com/sihokk/lear…
除夕快乐!!