控制相机是三维场景中基础而普遍的需求,如展示类项目中控制相机围绕物品全方位观察,游戏类项目中操控相机以第一人称视角移动。常见的相机控件有 orbitControl ,绕场景中某个锚点控制相机移动;freeControl,相机以自身为锚点进行旋转移动。 在编辑器中,除以上两种通过键盘鼠标配合控制相机的控件外,可视化的相机控件 - 导航控件必不可少。作为视图导航的一种固定方式,它能 (1) 直观的展示场景中相机的方向,(2) 方便的切换到固定视角,如顶视图、侧视图等三视图,并且(3) 仅需单键点击就能操作相机,极大降低了普通用户的使用门槛。下文中我们将针对导航控件的这三种主要功能进行拆解。
实现的第一步,需要考虑如何将导航控件显示在画面中。根据构想,导航控件和主画面属于同一场景,相机信息上同步,操作上互不干扰。类似于游戏中略缩地图、装备总览。一般以多相机的思路进行实现。即在场景中添加另一相机,将该导航控件与这一相机的 cullingmask (裁剪遮罩,用来选择性地渲染场景中的渲染组件) 放置在同一layer。
构想中,导航控件功能是能同步场景相机的方向,也能对相机进行操控。
第一步,如何同步场景相机的方向?很自然的想到是将相机的 rotationQuaternion
赋予导航控件。但如此实现后,会发现和预期完全不同。让我们重新思考,导航控件的三个轴到底展示的是什么?想象场景在原点有一个代表 XYZ 方向的轴,移动场景相机可以观察到该坐标轴的相对变化,这才是约定俗成的导航控件要展示的内容,而非场景相机自身方向。
因此,场景相机和导航控件的worldMatrix应该是互逆的。场景相机因为相机特性,自身具有 viewMatrix
,从定义上与场景相机实体的 worldMatrix
互逆,即与控件的 worldMatrix
相同。
而导航控件的位置是固定的,没有位置变化。用矩阵的语言表示,就是在齐次坐标中表示位置变化的最后一列的 可以置为零。
tempMat.copyFrom(sceneCamera.viewMatrix);
const { elements: ele } = tempMat;
// ignore translate
ele[12] = ele[13] = ele[14] = 0;
gizmoEntity.transform.worldMatrix = tempMat;
导航控件另一主要功能是控制场景相机切换到对应的三视图视角。值得注意的是,三视图从定义上而言,不是仅扭转相机自身的方向向上向侧观看,而是也要绕观察物体移动到相应的方位上。想象一个以观察对象为中心的球体,我们需要的是控制场景相机在这个球上移动到指定位置,并且扭转朝向。
那么如何获得特定三视图对应的场景相机worldMatrix
呢?一般而言,matrix
有lookAt
方法,获得的lookAtMatrix
即为相机的viewMatrix
:
Matrix.lookAt(eye, target, upVec, viewMat);
那么其中各个分量target
、eye
、upVec
如何获得?
target
即看向的对象。在场景中,有什么因素会改变场景相机的 target
?除主动更改外,这里就要考虑在该场景相机上是否有 orbitControl
组件了。orbitControl有三种操作 orbit
、zoom
、pan
,其中orbit
、zoom
虽然改变了相机位置,但都围绕着同一中心进行,而pan
则会在移动相机位置的同时改变lookAt
的target
。因此导航控件需要分同一相机上有无orbitControl
组件两种情况进行讨论:如果有orbitControl
就调用其target
;如果没有则需要用户指定target
。eye
即场景相机期望的位置。确定了特定三视图的方向后,通过 target
、forwardVector
与 radius
组合计算得到。upVector
一般为(0,1,0),顶视图和底实图建议使用(0,0,1)或(0,0,-1)// target
// 场景相机有 orbitControl 控件
const target = orbitControl.target
// 场景相机无 orbitControl 控件, 指定位置
const target = new Vector3(...);
// eye
Vector3.subtract(sceneCameraEntity.transform.worldPosition, target, tempVect);
const radius = tempVect.length();
// 根据需要三视图获得方向,以右视图为例
const directVec = new Vector3(0,radius,0)
Vector3.add(directVec, target, eye);
// up direction
const upVec = new Vector3(0,1,0)
// 获得场景相机实体worldMatrix
Matrix.lookAt(eye, target, upVec, tempMat);
tempMat.invert()
sceneCameraEntity.transform.worldMatrix = tempMat;
我们是通过在屏幕上鼠标的拖拽来改变场景相机的方位,鼠标的移动是二维的,而相机则是在三维空间中产生了变化,如何通过二维的交互来操控三维场景呢? 正如上文想象相机是在一个球面上移动,一个思路是拖拽球体表面,从而改变相机方位。这个思路直观,但有明显的缺点,因为是在屏幕二维空间操作,每次拖拽球体很难转过180度到达背面;并且涉及球体计算复杂。
我们采用的思路是,把拖拽分解为横轴与纵轴两个方向,屏幕空间 x 方向上的差值对应为绕着纵轴转过的角度,屏幕空间 y 方向上的差值对应为绕着横轴轴转过的角度。在世界空间中,纵轴即为 y 轴方向,横轴为与场景相机平行的方向。这样的对应即能达到拖拽相机的效果,并且可以通过增加系数控制转速。
// 分解为屏幕空间中x、y方向上每帧移动差值
let x = deltaPointer.x
let y = deltaPointer.y
// 计算得出横轴
horizontalAxis.copyFrom(sceneCameraEntity.transform.worldForward);
Vector3.cross(horizontalAxis, upVec, horizontalAxis);
// 获得对应旋转角度
Quaternion.rotationAxisAngle(horizontalAxis, y, tempQuat);
Quaternion.rotationYawPitchRoll(x, 0, 0, tempQuat2);
Quaternion.multiply(tempQuat, tempQuat2, tempQuat);
在业务场景中,该导航组件可视化程度高、无使用门槛,可以作为用户操作轴来控制场景。在实现上,可以为其他需要在主画面上显示、信息同步但操作独立的组件提供思路借鉴。在计算上,涵盖了相机最常使用的lookAt Matrix
、viewMatrix
,以及相机实体的特殊性质,从而帮助引擎使用者更好的理解相机。
源码地址: https://github.com/galacean/engine-toolkit
导航示例: https://galacean.antgroup.com/#/examples/latest/gizmo
多相机示例: https://galacean.antgroup.com/#/examples/latest/multi-camera