本系列文章主要是基于LearnOpenGL和对应的中文教程。与原教程主要的差异是,该系列讲解的是基于Android设备环境的OpenGL ES,并提供对应的Java示例。
原文:Camera
中文原文:摄像机
前面的教程中我们讨论了观察矩阵以及如何使用观察矩阵移动场景(我们向后移动了一点)。OpenGL本身没有摄像机(Camera)的概念,但我们可以通过把场景中的所有物体往相反方向移动的方式来模拟出摄像机,产生一种我们在移动的感觉,而不是场景在移动。
本节我们将会讨论如何在OpenGL中配置一个摄像机,并且将会讨论FPS风格的摄像机,让你能够在3D场景中自由移动。我们也会讨论键盘和鼠标输入,最终完成一个自定义的摄像机类。
摄像机/观察空间
当我们讨论摄像机/观察空间(Camera/View Space)的时候,是在讨论以摄像机的视角作为场景原点时场景中所有的顶点坐标:观察矩阵把所有的世界坐标变换为相对于摄像机位置与方向的观察坐标。要定义一个摄像机,我们需要它在世界空间中的位置、观察的方向、一个指向它右侧的向量以及一个指向它上方的向量。细心的读者可能已经注意到我们实际上创建了一个三个单位轴相互垂直的、以摄像机的位置为原点的坐标系。
1. 摄像机位置
获取摄像机位置很简单。摄像机位置简单来说就是世界空间中一个指向摄像机位置的向量。我们把摄像机位置设置为上一节中的那个相同的位置(这次是移动摄像机,而不是场景):
Vec3 cameraPos = new Vec3(0.0f, 0.0f, 3.0f);
不要忘记正z轴是从屏幕指向你的,如果我们希望摄像机向后移动,我们就沿着z轴的正方向移动。
2. 摄像机方向
下一个需要的向量是摄像机的方向,这里指的是摄像机指向哪个方向。现在我们让摄像机指向场景原点:(0, 0, 0)。还记得如果将两个矢量相减,我们就能得到这两个矢量的差吗?用场景原点向量减去摄像机位置向量的结果就是摄像机的指向向量。对于观察矩阵的坐标系统,我们希望它的z轴是正的。因为按照惯例(在OpenGL中),摄像机指向负z轴,我们需要翻转方向向量。如果我们交换相减的顺序,我们就会获得一个指向摄像机正z轴方向的向量:
Vec3 cameraPos = new Vec3(0.0f, 0.0f, 3.0f);
Vec3 cameraTarget = new Vec3(0.0f, 0.0f, 0.0f);
cameraTarget = cameraPos.minus(cameraTarget);
//第二个参数其实就是用来接收返回结果的,即cameraDirection
//如果是kotlin代码调用,是有默认传参的,不需要传。
//后续的直接传new Vec3()这种,都是类似原因,不再赘述。
//cameraDirection为(0.0f, 0.0f, 1.0f)
Vec3 cameraDirection = glm.normalize(cameraTarget, new Vec3());
方向向量(Direction Vector)并不是最好的名字,因为它实际上指向从它到目标向量的相反方向(译注:注意看前面的那个图,蓝色的方向向量大概指向z轴的正方向,与摄像机实际指向的方向是正好相反的)。
3. 右轴
我们需要的另一个向量是一个右向量(Right Vector),它代表摄像机空间的x轴的正方向。为获取右向量我们需要先使用一个小技巧:先定义一个上向量(Up Vector)。接下来把上向量和第二步得到的方向向量进行叉乘。两个向量叉乘的结果会同时垂直于两向量,因此我们会得到指向x轴正方向的那个向量(如果我们交换两个向量叉乘的顺序就会得到相反的指向x轴负方向的向量):
Vec3 up = new Vec3(0.0f, 1.0f, 0.0f);
//cameraRight为(1.0f, 0.0f, 0.0f)
Vec3 cameraRight = glm.normalize(glm.cross(up, cameraDirection, new Vec3()), new Vec3());
4. 上轴
现在我们已经有了x轴向量和z轴向量,获取一个指向摄像机的正y轴向量就相对简单了:我们把右向量和方向向量进行叉乘:
//cameraUp为(0.0f, 1.0f, 0.0f),其实就是前面定义的单位向量up
Vec3 cameraUp = glm.cross(cameraDirection, cameraRight,new Vec3());
在叉乘和一些小技巧的帮助下,我们创建了所有构成观察/摄像机空间的向量。对于想学到更多数学原理的读者,提示一下,这个处理在线性代数中叫做格拉姆—施密特正交化(Gram-Schmidt Process)。使用这些摄像机向量我们就可以创建一个LookAt矩阵了,它在创建摄像机的时候非常有用。
Look At
使用矩阵的好处之一是如果你使用3个相互垂直(或非线性)的轴定义了一个坐标空间,你可以用这3个轴外加一个平移向量来创建一个矩阵,并且你可以用这个矩阵乘以任何向量来将其变换到那个坐标空间。这正是LookAt矩阵所做的。现在有了3个相互垂直的轴和一个位置向量来定义一个摄像机空间,我们就可以创建我们自己的LookAt矩阵了:
其中是右向量,是上向量,是方向向量是摄像机位置向量。注意,位置向量是相反的,因为我们最终希望把世界平移到与我们自身移动的相反方向。把这个LookAt矩阵作为观察矩阵可以很高效地把所有世界坐标变换到刚刚定义的观察空间。LookAt矩阵就像它的名字表达的那样:它会创建一个看着(Look at)给定目标的观察矩阵。
幸运的是,GLM已经提供了这些支持。我们要做的只是定义一个摄像机位置,一个目标位置和一个表示世界空间中的上向量的向量(我们计算右向量使用的那个上向量)。接着GLM就会创建一个LookAt矩阵,我们可以把它当作我们的观察矩阵:
Mat4 view = glm.lookAt(new Vec3(0.0f, 0.0f, 3.0f),
new Vec3(0.0f, 0.0f, 0.0f),
new Vec3(0.0f, 1.0f, 0.0f));
- Mat4 lookAt(Vec3 eye, Vec3 center, Vec3 up)
- eye:摄像头位置
- center:摄像头看着的位置,即注视点。
- up:上向量,需要经过标准化处理,即normalize。通常是一般为(0, 0, 1)
我们来做些有意思的事:我们将摄像机的注视点保持在(0, 0, 0),然后让摄像机绕着注视点中旋转。
我们需要用到一点三角学的知识来在每一帧创建一个x和z坐标,它会代表圆上的一点,我们将会使用它作为摄像机的位置。通过不断重新计算x和z坐标,我们会遍历圆上的所有点(即摄像机在圆上不断移动),这样摄像机就会绕着场景(摄像机注视点,即圆心(0, 0, 0))旋转了。我们预先定义这个圆的半径radius,在每次渲染迭代中更新摄像头的角度,重新创建观察矩阵:
long duration = 5000;
float camAngle = 0.0f;
long lastTime = -1L;
double base = Math.PI / duration;
float radius = 10.0f;
public void onDrawFrame(GL10 gl) {
...
if (lastTime == -1L) {
lastTime = System.currentTimeMillis();
} else {
long interval = System.currentTimeMillis() - lastTime;
lastTime = System.currentTimeMillis();
camAngle += interval * base;
}
float camX = glm.sin(camAngle) * radius;
float camZ = glm.cos(camAngle) * radius;
Mat4 view = glm.lookAt(new Vec3(camX, 0.0f, camZ),
new Vec3(0.0f, 0.0f, 0.0f),
new Vec3(0.0f, 1.0f, 0.0f));
...
}
如果你运行代码,应该会得到下面的结果:
通过这一小段代码,摄像机现在会随着时间流逝围绕场景转动了。
我们可以试试改变半径和位置/方向参数,看看LookAt矩阵是如何工作的:
如果你在哪卡住的话,可以参考CameraCircleRender。
自由移动
让摄像机绕着场景转的确很有趣,但是让我们自己移动摄像机,会更有趣!首先我们预定义下面变量:
Vec3 cameraPos = new Vec3(0.0f, 0.0f, 3.0f);
Vec3 cameraFront = new Vec3(0.0f, 0.0f, -1.0f);
Vec3 cameraUp = new Vec3(0.0f, 1.0f, 0.0f);
lookAt函数现在成了:
Mat4 view = glm.lookAt(cameraPos, cameraPos.plus(cameraFront), cameraUp);
我们将摄像机位置设置为之前定义的cameraPos。方向是当前的位置加上我们刚刚定义的方向向量。这样能保证无论我们怎么移动,摄像机都会注视着目标方向。让我们摆弄一下这些向量,在按下某些按钮时更新cameraPos向量。
当我们按下方向键的任意一个,摄像机的位置都会相应更新。如果我们希望向前或向后移动,我们就把位置向量加上或减去方向向量。如果我们希望向左右移动,我们使用叉乘来创建一个右向量(Right Vector),并沿着它相应移动就可以了。这样就创建了使用摄像机时熟悉的横移(Strafe)效果。
public void move(Direction d, float cameraSpeed) {
switch (d) {
case UP:
//即cameraPos += cameraFront * cameraSpeed
cameraPos = cameraPos.plus(cameraFront.times(cameraSpeed));
break;
case DOWN:
//即cameraPos -= cameraFront * cameraSpeed;
cameraPos = cameraPos.minus(cameraFront.times(cameraSpeed));
break;
case LEFT:
Vec3 cameraRight = glm.normalize(glm.cross(cameraFront, cameraUp, new Vec3()), new Vec3());
//即cameraPos -= cameraRight * cameraSpeed
cameraPos = cameraPos.minus(cameraRight.times(cameraSpeed));
break;
case RIGHT:
cameraRight = glm.normalize(glm.cross(cameraFront, cameraUp, new Vec3()), new Vec3());
//即cameraPos += cameraRight * cameraSpeed;
cameraPos = cameraPos.plus(cameraRight.times(cameraSpeed));
break;
}
}
注意,我们对右向量进行了标准化。如果我们没对这个向量进行标准化,最后的叉乘结果会根据
cameraFront变量返回大小不同的向量。如果我们不对向量进行标准化,我们就得根据摄像机的朝向不同加速或减速移动了,但如果进行了标准化移动就是匀速的。
接下来,让我们来尝试绘制一个方向盘,来控制摄像头的移动:
方向盘的移动范围就是大圆圈ScopeCircle,(cx,cy)就是它的圆心。白色的小圆FingerCircle,就代表以我们的手指落点(x,y)为圆心,绘制的小圆。
float fingerCircleRadius;
float scopeCircleRadius;
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
scopeCircleRadius = getScopeRadius();
fingerCircleRadius = getFingerCircleRadius();
cx = getMeasuredWidth() / 2f;
cy = getMeasuredHeight() / 2f;
}
绘制这样两个圆对我们而言并不困难。但是注意,在手指移动的过程中,当我们手指的落点靠近ScopeCircle的边界时,白色小圆FingerCircle可能会部分逸出到ScopeCircle外,这会给人带来怪异的感觉。所以,为了用户体验,这时候我们需要调整手指的落点,让白色小圆与大圆圈相切。
如何判断FingerCircle是否部分逸出到了ScopeCircle外?如下图:
只要我们计算出手指落点(x,y)到(cx,cy)的距离,最后加上FingerCircle的半径,即可判断:
double distCenter = Math.sqrt(Math.pow(x - cx, 2) + Math.pow(y - cy, 2));
//FingerCircle部分逸出到了ScopeCircle外
if (distCenter + fingerCircleRadius > scopeCircleRadius) {
...
}
如何调整手指落点,让FingerCircle与ScopeCircle相切呢?
如上图,设手指实际落点与点(cx,cy)的连线是线段AB。线段AB与ScopeCircle的交点,就是手指落点调整后,FingerCircle与ScopeCircle相切的点(scopeX,scopeY)。线段AB与X轴的夹角为 ,根据三角函数,很容易算出点(scopeX,scopeY):
float cos = (float) ((x - cx) / distCenter);
float sin = (float) ((y - cy) / distCenter);
float scopeX = cx + scopeCircleRadius * cos;
float scopeY = cy + scopeCircleRadius * sin;
最后就只剩下算出调整后的手指落点(x',y')了。点(x',y')与点(cx,cy)的连线,与x轴方向形成的夹角其实是 。
//调整fingerCircle落点位置,让fingerCircle刚好与ScopeCircle内切
x = scopeX + fingerCircleRadius * (-cos);
y = scopeY + fingerCircleRadius * (-sin);
完整的绘制代码如下:
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawCircle(cx, cy, scopeCircleRadius, scopePaint);
if (!isMove) return;
float x = fingerX;
float y = fingerY;
double distCenter = Math.sqrt(Math.pow(x - cx, 2) + Math.pow(y - cy, 2));
//FingerCircle部分逸出到了ScopeCircle外
if (distCenter + fingerCircleRadius > scopeCircleRadius) {
//FingerCircle有部分在ScopeCircle外,那就获取线段AB(起点(cx,cy),终点(x,y))与ScopeCircle
//相交的点(scopeX,scopeY)
float cos = (float) ((x - cx) / distCenter);
float sin = (float) ((y - cy) / distCenter);
float scopeX = cx + scopeCircleRadius * cos;
float scopeY = cy + scopeCircleRadius * sin;
//调整手指落点位置,让FingerCircle刚好与ScopeCircle内切
x = scopeX + fingerCircleRadius * (-cos);
y = scopeY + fingerCircleRadius * (-sin);
}
canvas.drawCircle(x, y, fingerCircleRadius, fingerPaint);
this.x = x;
this.y = y;
isUpdate = true;
}
还有另外一件事,不要忘了做。那就是通过onTouchEvent监听触摸事件:
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
isMove = true;
//定时器。每16.6ms获取一次手指落点,用于移动摄像头。
//这里没有采用Timer,主要是在一些低端设备,尤其是机顶盒上,
//发现Timer存在不准时的情况(并不是因为Android的Doze模式的影响)。
moveTimer = ValueAnimator.ofFloat(0, 1);
moveTimer.addUpdateListener(animation -> {
//定时器刚执行时,可能还没有获取到有效的x、y
if (!isUpdate) return;
onMove(cx, cy, x, y);
});
moveTimer.setDuration(1000);
moveTimer.setRepeatCount(ValueAnimator.INFINITE);
moveTimer.setInterpolator(new LinearInterpolator());
moveTimer.start();
break;
case MotionEvent.ACTION_MOVE:
fingerX = event.getX();
fingerY = event.getY();
invalidate();
break;
case MotionEvent.ACTION_UP:
isMove = false;
isUpdate = false;
invalidate();
moveTimer.cancel();
break;
default:
break;
}
return true;
}
onMove获取手指落点后,我们就可以根据手指落点,判断用户想要移动摄像头的方向。在这里,我们还将根据手指落点(x,y)离点(cx,cy)的距离,给予摄像头不同的移动速度:
public void onMove(float cx, float cy, float x, float y) {
if (onMoveListener == null) return;
float dx = x - cx;
float dy = y - cy;
Direction horizontal = dx >= 0 ? RIGHT : LEFT;
if (getSpeed(dx) > 0.0f) onMoveListener.move(horizontal, getSpeed(dx));
Direction vertical = dy >= 0 ? DOWN : UP;
if (getSpeed(dy) > 0.0f) onMoveListener.move(vertical, getSpeed(dy));
}
private float getSpeed(float d) {
d = Math.abs(d);
float speed = 0.0f;
if (d > scopeCircleRadius / 5 && d <= scopeCircleRadius * 2 / 5) {
speed = 0.02f;
} else if (d > scopeCircleRadius * 2 / 5 && d <= scopeCircleRadius * 3 / 5) {
speed = 0.04f;
} else if (d > scopeCircleRadius * 3 / 5) {
speed = 0.08f;
}
return speed;
}
监听手指的移动,我们就能更新摄像头的位置了:
binding.moveView.setOnMoveListener((d, speed) -> {
binding.cameraPosition.move(d, speed);
});
由于手指落在方向盘上时,我们会根据手指落点的变换,不停更新移动指令。只有接收在移动指令时,才需要更新GLSurfaceView的画面,所以我们可以采用按需渲染:
setRenderMode(RENDERMODE_WHEN_DIRTY);
按需渲染,在需要更新画面的时候,调用requestRender即可。
public void move(Direction d) {
cameraPositionRender.move(d);
requestRender();
}
最后的效果应该是这样子:
方向盘的实现可以参考MoveView。摄像头移动的实现可以参考CameraPositionRender。
视角移动
前面我们实现了摄像头的移动。但是我们的摄像头只能正视着前方移动,还不能转向,移动很受限制。
接下来,我们将学习如何改变视角。
欧拉角
欧拉角(Euler Angle)是可以表示3D空间中任何旋转的3个值,由莱昂哈德·欧拉(Leonhard Euler)在18世纪提出。一共有3种欧拉角:俯仰角(Pitch)、偏航角(Yaw)和滚转角(Roll),下面的图片展示了它们的含义:
俯仰角是描述我们如何往上或往下看的角,可以在第一张图中看到。第二张图展示了偏航角,偏航角表示我们往左和往右看的程度。滚转角代表我们如何翻滚摄像机,通常在太空飞船的摄像机中使用。每个欧拉角都有一个值来表示,把三个角结合起来我们就能够计算3D空间中任何的旋转向量了。
对于我们的摄像机系统来说,我们只关心俯仰角和偏航角,所以我们不会讨论滚转角。给定一个俯仰角和偏航角,我们可以把它们转换为一个代表新的方向向量的3D向量。俯仰角和偏航角转换为方向向量的处理需要一些三角学知识,我们先从最基本的情况开始:
如果我们把直角三角形的斜边边长定义为1,我们就能知道邻边的长度是 ,它的对边是 。依靠这些通用公式,我们可以根据所给的角度,求出直角三角形x和y边的长度。我们也可以使用它来计算方向向量的分量。
让我们想象在3D空间中,有这样一个相同的三角形。不过现在我们从顶部视角来看,它的邻边和对边平行于场景的x轴和z轴(就像向下看y轴):
如果我们把偏航角想象成从x边开始的逆时针角,我们可以看到x边的长度与偏航角的余弦有关,即cos(yaw)。同样,z边的长度与偏航角的正弦有关,即sin(yaw)。
如果我们利用这些知识和一个给定的偏航值,我们可以使用它来创建一个摄像机方向向量:
Vec3 direction = new Vec3();
direction.x = (float)Math.cos(glm.radians(yaw)); //注意我们要先把角度转换成弧度
direction.z = (float)Math.sin(glm.radians(yaw));
这解决了我们如何从偏航值获得3D方向矢量,但pitch也需要包括在内。现在我们来看看y轴,就像我们在xz平面上一样:
同样地,从这个三角形中我们可以看到方向向量的y分量等于俯仰角的正弦,即sin(pitch)。所以我们把它填进去:
direction.y = (float)Math.sin(glm.radians(pitch));
然而,从上面的pitch三角形中,我们也可以看到x/z边受俯仰角的余弦影响,即cos(pitch)。所以我们需要确保这也是方向向量的一部分。加上这个,我们就得到了从偏航角和俯仰欧拉角转换后的最终方向矢量:
direction.x = (float)Math.cos(glm.radians(yaw)) * (float)Math.cos(glm.radians(pitch));
direction.y = (float)Math.sin(glm.radians(pitch));
direction.z = (float)Math.sin(glm.radians(yaw)) * (float)Math.cos(glm.radians(pitch));
这给了我们一个公式,把偏航角和俯仰角转换成三维方向向量。我们可以用它来观察四周。
我们已经设置了场景世界,所以所有内容都位于z轴的负方向。然而,如果我们观察x和z偏航三角形,我们会看到,当 为0时,会导致相机的方向向量指向正x轴。为了确保相机默认指向负z轴,我们可以给偏航一个默认值,即顺时针旋转90度。正的角度是逆时针旋转的,所以我们设置默认偏航值为:
float yaw = -90.0f;
我们如何修改偏航角和俯仰角?
我们新增一个和前面方向盘类似的欧拉盘:水平的移动影响偏航角,竖直的移动影响俯仰角。具体的实现代码和方向盘基本类似,这里就不再展开了。
另外,为了避免一些奇怪的问题,我们需要给摄像机添加一些限制。
对于俯仰角,要让用户不能看向高于89度的地方(在90度时视角会发生逆转,所以我们把89度作为极限),同样也不允许小于-89度。这样能够保证用户只能看到天空或脚下,但是不能超越这个限制。我们可以在值超过限制的时候将其改为极限值来实现:
if (pitch > 89) {
pitch = 89;
} else if (pitch < -89) {
pitch = -89;
}
对于偏航角,我们没有设置限制。这是因为我们不希望限制用户的水平旋转。当然,给偏航角设置限制也很容易,如果你愿意可以自己实现。
现在我们就可以自由地在3D场景中移动了!(左边是方向盘,右边是欧拉盘)
欧拉盘的实现代码可以参考AngleView。视角移动的实现代码可以参考CameraViewRender。
缩放
最后,我们来实现一个缩放功能。在之前的教程中我们说视野(Field of View)或fov定义了我们可以看到场景中多大的范围。当视野变小时,场景投影出来的空间就会减小,产生放大(Zoom In)了的感觉。
前面我们创建透视投影矩阵时,是采用45.0f作为固定视野值,现在我们使用变量fov:
float fov = 45.0f;
Mat4 projection = glm.perspective(glm.radians(fov), (float) width / (float) height, 0.1f, 100.0f);
当我们想要进行缩放时,只需要调整fov的值即可。不过,我们将会把缩放级别(Zoom Level)限制在1.0f到45.0f。
float minFov = 1.0f;
float maxFov = 45.0f;
if (fov > maxFov) {
fov = maxFov;
} else if (fov < minFov) {
fov = minFov;
}
现在,我们接着来实现一个双指缩放功能。由于fov越小时,视野就越小,产生放大的效果;fov越大,视野就越大,产生放小的效果,所以当我们两指靠近时,就增大fov,反之减小fov。我们通过onTouchEvent监听双指触控的情况,不断计算两指之间的距离dist,根据前后两次的距离差,调整fov。
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_POINTER_DOWN: {
//刚好双指
if (event.getPointerCount() == 2) {
lastDist = (float) getTwoFingersDistance(event);
}
break;
}
case MotionEvent.ACTION_MOVE: {
if (event.getPointerCount() == 2) {
float dist = getTwoFingersDistance(event);
float offset = getFovOffset(lastDist - dist);
fov = fov + offset;
lastDist = dist;
if (fov > maxFov) {
fov = maxFov;
} else if (fov < minFov) {
fov = minFov;
}
cameraFovRender.setFov(fov);
requestRender();
}
break;
}
}
return true;
}
private float getTwoFingersDistance(MotionEvent event) {
float x0 = event.getX(0);
float y0 = event.getY(0);
float x1 = event.getX(1);
float y1 = event.getY(1);
return (float) Math.sqrt(Math.pow(x0 - x1, 2) + Math.pow(y0 - y1, 2));
}
private float getFovOffset(float d) {
int max = Math.min(getWidth(), getHeight());
float unit = (maxFov - minFov) / max;
return d * unit;
}
现在,我们就实现了一个简单的摄像机系统了,它能够让我们在3D环境中自由移动。
双指缩放的代码,可参考CameraFov和CameraFovRender。
注意,使用欧拉角的摄像机系统并不完美。根据你的视角限制或者是配置,你仍然可能引入万向节死锁问题。最好的摄像机系统是使用四元数(Quaternions)的,但我们将会把这个留到后面讨论。(译注:这里可以查看四元数摄像机的实现)
摄像机类
接下来的教程中,我们将会一直使用一个摄像机来浏览场景,从各个角度观察结果。然而,由于摄像机相关代码会占用每篇教程很大的篇幅,所以这里对它进行了一定的封装,抽象成一个Camera类。后续教程将使用该Camera类,快速创建我们的摄像机对象。
我们介绍的摄像机系统是一个类飞行的摄像机,它能够满足大多数情况需要,而且与欧拉角兼容。但是在创建不同的摄像机系统,比如FPS摄像机、飞行模拟摄像机时就要当心。每个摄像机系统都有自己的优点和不足,所以确保对它们进行了详细研究。比如,这个飞行摄像机不允许俯仰角大于90度,而且我们使用了一个固定的上向量(0, 1, 0),这在需要考虑滚转角的时候就不适用了。