3D游戏编程中,镜头的控制相当重要,不同的镜头表现,能给玩家完全不同的体验;比如《跑跑卡丁车》中的跟随镜头,每当甩尾的时候,镜头也会有相应的运动轨迹,如果只是单单的垂直俯视,那肯定全无甩尾的感觉。废话少说,这里分析下魔兽世界中主角的跟随镜头行为,因为这个跟随模型很简单,网上也很少有镜头跟随模型相关的文章,希望能起到个抛砖引玉的效果。阅读本文需要有一定3D图形学基础。
在wow中,主角的镜头主要可以分解为3种运动,这3中运动相互独立,为编程带来了方便:
1.镜头forward方向的进、退,根据鼠标滚轮控制
2.绕主角垂直中心轴的转动,鼠标按住左键后左右移动控制
3.绕某水平轴的转动,鼠标按住左或右键后上下移动控制
玩过wow的朋友可以细想一下,主要方式是不是就这三种。其它方面,比如wow中有个选项,主角移动的时候镜头会自动旋转到人物后方;还有就是镜头因为场景碰撞离主角很近以后,当碰撞没有的时候,会自动退回到原始的跟随距离等。这里将分析并实现以上描述的5点。
接下来做一些约定:
世界坐标的垂直向上方向为Y轴正方向
同时主角的Y轴始终与世界Y轴一致,主角可在XOZ平面内旋转
主角的朝向为其Z轴正方向,主角的X轴和Z轴始终在XOZ平面内,即其y分量为0
镜头的朝向为其Z轴正方向,镜头的X轴始终水平,在XOZ平面内
镜头与场景采用球形碰撞
不太明白的可以看示意图:
相机跟随模型侧视图
侧视图中,椭球体代表主角,点T是主角的Root Bone位置,一般为脚底;点P是相机位置;点A是主角垂直中心轴上一个点,相机始终盯着这个点
相机跟随模型俯视图,对应3种运动的第2种
俯视图中可以看到,主角的Z轴代表其正前方,相机的Z轴代表其观察方向
在这里,相机的运动其实就是向量P->A的运动:
P->A缩短、变长就是运动方式1
P->A绕主角垂直中心轴360°无限制旋转就是运动方式2,见俯视图
P->A绕经过A点的某水平轴有限制旋转就是运动方式3
下面看下运动方式3的有限制旋转示意图:
相机俯仰角示意图,对应3种运动的第3种
theta角为向量P->A上下旋转的最大限制角,可以设定为上下对称,也可以设定为不对称(不过需要另外写代码判断,这里不做分析)
为简化编程,我们将向量P->A的运动在跟随目标(主角)的局部坐标系内先实现,再转换到世界坐标系。跟随目标的X轴和Z轴见‘相机跟随模型俯视图’,Y轴见‘相机跟随模型侧视图’,在此局部坐标系中,点T成为坐标系原点
鼠标的运动可以分为三种:左右移动,上下移动和滚轮滚动,这里分别叫做delta X, delta Y,delta Z,简称 dx, dy, dz,dx、dy的符号跟屏幕坐标系定义有关,这些参数可以从系统处获取,这里不讲。
鼠标左右移动的时候,dx不为0,向左dx < 0,向右dx > 0
鼠标上下移动的时候,dy不为0,向上dy < 0,向下dy > 0
鼠标滚动轮动的时候,dz不为0
下面根据实现代码逐步分析,代码是用python写的,熟悉c/c++的朋友很容易看懂,代码复制下来并不能直接运行,因为依赖其它模块,要用到自己游戏中需要做些修改
先定义一个类CMouseInputEvent,上层逻辑将镜头用到的相关输入通过该类传递给镜头控制类:
KEY_PAD = 0x01
KEY_TRG = 0x02
KEY_RLS = 0x04
#KEY_PAD表示按键当前是按下状态,KEY_TRG表示按键当前有按下动作,KEY_RLS表示按键当前有松开动作
#下面的lbutton、rbutton、mbutton都是KEY_PAD/KEY_TRG/KEY_RLS按位或的组合
class CMouseInputEvent:
def __init__(self): #构造函数
self.lbutton = 0 #left button
self.rbutton = 0 #right button
self.mbutton = 0 #midden button
self.mousedx = 0
self.mousedy = 0
self.mousedz = 0
def setMouse(self, lbt, rbt, mbt, dx, dy, dz):
self.lbutton = lbt
self.rbutton = rbt
self.mbutton = mbt
self.mousedx = dx
self.mousedy = dy
self.mousedz = dz
class CFollowCameraAI:
def __init__(self, cmr, scn, target, ofst, lkatofst):
self.UnitY = math3d.vector(0, 1, 0) #向上的Y轴
self.input = CMouseInputEvent()
self._camera = cmr #场景camera引用
self._scene = scn #场景引用
#跟随目标
self.followTarget = target #跟随目标引用
self.cameraOffset = ofst #camera位置相对跟随目标的偏移,向量T->P,局部坐标系参数
self.lookatOffset = lkatofst #camera lookAt的点相对跟随目标的偏移,向量T->A,局部坐标系参数
#控制参数
self.minOffsetLen = 5.0 #最小跟随距离,向量P->A的最短距离
self.maxOffsetLen = 168.0 #最大跟随距离,向量P->A的最长距离
self.limitTgn = math.tan(1.4) #最大俯视/仰视角度(1.4弧度大约为80°)
#速度控制
self.x_scroll_speed = 0.008 #X,左右方向旋转速度,控制向量P->A绕目标Y轴旋转
self.y_scroll_speed = -0.0022 #Y,上下方向旋转速度,控制向量P->A绕H平面旋转
self.z_scroll_speed = 0.05 #Z,前后方向滚动速度,控制向量P->A长短
self.rotback_angular_speed = 0.1 #自动回转速度,控制同x_scroll_speed
self.z_backaway_speed = 40.0 #轴向位置插值速度,控制同z_scroll_speed
#碰撞检测
self.radius = 0.8 #camera采用球形碰撞,这是碰撞球半径
self.collided_shorten = False #某内部状态标志位
u = math3d.vector(1, 1, 1) * self.radius
self._col_shape = collision.col_object(collision.SPHERE, u) #构建球形碰撞体
def Update(self, dTime, bTargetMoving, bRotBackEnabled):
#dTime,帧时间,单位秒
#bTargetMoving, bool, 目标是否在移动
#bRotBackEnabled, bool, 是否允许camera横向转回到跟随目标背后
rot_back_request = bTargetMoving #跟随目标移动的时候才转到背后,停下的时候不会转
LookAt = self.lookatOffset - self.cameraOffset #向量P->A
#远近控制
if self.input.mousedz != 0:
curLength = abs(LookAt) #向量当前长度
LookAt.normalize()
#根据滚轮位移计算需要缩放向量P->A多少距离,并限定其范围
curLength -= self.input.mousedz * self.z_scroll_speed
if curLength > self.maxOffsetLen:
curLength = self.maxOffsetLen
elif curLength
A绕目标Y旋转一定角度
#横向旋转矩阵
upRot = math3d.matrix.make_rotation(self.UnitY, self.input.mousedx * self.x_scroll_speed)
LookAt *= upRot #旋转向量P->A,横向
#上下控制
if self.input.mousedy != 0:
#左中右三键都可以控制上下方向
if ( ((self.input.lbutton & KEY_PAD) and (not (self.input.lbutton & KEY_TRG)))
or ( (self.input.rbutton & KEY_PAD) and (not (self.input.rbutton & KEY_TRG)))
or ( (self.input.mbutton & KEY_PAD) and (not (self.input.mbutton & KEY_TRG))) ):
#计算纵向旋转轴,该轴与目标Y和Z垂直;相当于目标将其Z方向旋转到向量P->A的水平分量方向
right = LookAt.cross(self.UnitY)
right.normalize()
#纵向旋转矩阵
rightRot = math3d.matrix.make_rotation(right, self.input.mousedy * self.y_scroll_speed)
temp = LookAt * rightRot #旋转向量P->A,纵向
#纵向旋转后需要做俯仰角限定
#先计算当前水平分量对应的最大垂直分量
maxY = math.sqrt(temp.x * temp.x + temp.z * temp.z) * self.limitTgn
if temp.y < -maxY:
temp.y = -maxY
elif temp.y > maxY:
temp.y = maxY
temp.normalize()
LookAt = temp * abs(LookAt) #最终方向向量 * 向量P->A原始长度
#放开左键后,自动转到目标背面
if bRotBackEnabled and rot_back_request and not (self.input.lbutton & KEY_PAD):
dst = math3d.vector(0, 0, -1) #要转到哪根轴(跟随目标的局部坐标系,正前方是(0,0,1),背后就是(0,0,-1))
src = math3d.vector(LookAt.x, 0, LookAt.z) #当前轴水平分量
src.normalize() #src归一化,方便计算,dst也是归一化向量
t = src.cross(dst) #src转到dst,因此旋转轴是src叉乘dst,因为是两个水平向量叉乘,结果应该是垂直方向的向量
cp = t.dot(self.UnitY) #cp = t.y = sin(theta),src、dst、self.UnitY的混合积
dp = src.dot(dst) #计算src到dst的夹角,dp = cos(theta);当src与dst重合时,dp == 1或-1;当src与dst垂直时,dp == 0
abcp = abs(cp) #因为是src叉乘dst,sin(theta)有正负之分;当src与dst重合时,cp == 0;当src与dst垂直时,cp == 1
#非尾部且非夹角很小;当dp> 0时说明src与dst夹角小于90°,即src在主角背后;当dp<0时src在主角前方
if not ((abcp < 0.1) and (dp > 0)):
r = self.rotback_angular_speed
if abcp >= 0.1: #非正向且非夹角很小
r *= cp / abcp
if abs(r) > abcp:
r = -cp #旋转角度比实际夹角小
yRotBack = math3d.matrix.make_rotation(self.UnitY, r)
LookAt *= yRotBack
#计算需求位置
mat = self.followTarget.rotation_matrix #跟随目标的方向矩阵
mat.translation = self.followTarget.position #跟随目标的位置,点T
fwd = mat.mulvec3x3(LookAt) #fwd是LookAt的世界坐标,将最终变成camera的方向向量
self.cameraOffset = self.lookatOffset - LookAt #self.cameraOffset的局部坐标(局部坐标-局部向量)
ofst = self.cameraOffset * mat #ofst是self.cameraOffset的世界坐标,点P
#碰撞检测,需要在世界坐标系里执行
start = self.lookatOffset * mat #start是self.lookatOffset的世界坐标,点A
delta = ofst - start #ofst是self.cameraOffset的世界坐标,点P
#由近及远插值
if self.collided_shorten: #如果相机在自己z轴向遇到过碰撞,即向量P->A当前长度可能小于预定长度,则缓慢插值到预定长度
curOfstLen = abs(self._camera.position - start)
reqOfstLen = abs(delta)
if curOfstLen < reqOfstLen:
curOfstLen += self.z_backaway_speed * dTime * ((bTargetMoving and 2.0) or 1.0)
if curOfstLen > reqOfstLen:
curOfstLen = reqOfstLen
delta.normalize()
delta *= curOfstLen
ofst = start + delta
else:
self.collided_shorten = False
hit, hit_point, normal, tt, color, objs = self._scene.col.sweep_test(self._col_shape, start, start + delta)
if hit:
self.collided_shorten = True #上次曾碰到过,记个标志位
ofst = hit_point + normal * self.radius #沿碰撞表面法线外移radius,减少camera穿面情况
#设定最终方位
self._camera.set_placement(ofst, fwd, mat.up) #camera pos, camera forward vector, camera up vector
有不明白的地方可以交流,也欢迎更好的建议。