第三人称视角游戏的镜头全自动控制方案
1 | 全自动跟随方案的简介
1.1 方案的选择
视角的选择和控制,直接关乎玩家的体验。由于第三人称视角,相比第一人称,更能突出主角的动作行为,视角也更加开阔,已经被越来越多的游戏所采用。不过具体实现的效果,却不尽相同。
从实现原理上,第三人称视角实现的核心是镜头对主角跟随的处理机制。通常都会遇到这样一些问题:
(1)镜头的上下左右转动是否完全交由玩家操控?如果不是,那么镜头如何能自动做到比较符合玩家直觉的视角呢?
(2)主角被遮挡时或者镜头陷入遮挡物内部时,要如何处理?是否要隐藏或半透明化遮挡物?
(3)什么情况下要拉近镜头?
……
如果处理不好,就会严重影响玩家的体验。比如:
(1)在玩家探索场景的时候,频繁的操作镜头转向,会给玩家带来较多的操作负担;如果自动转动的镜头与玩家直觉不是很契合,又会造成玩家不适,如果再提供给玩家手动修正的方式,实际上是给玩家平添了更多的麻烦。
(2)当主角被遮挡时,有的游戏会简单地处理成直接推近到可以看到玩家的地方,而这样往往会带来比较大幅度的镜头移动,一不小心就给玩家上了一个“眩晕”效果。
(3)对于遮挡物进行隐藏的体验是很奇怪的,想象一下,前一秒还看到眼前有一棵树,突然之间就没了是什么感觉?但是使用半透明的方案,需要非常细致地处理叠加显示造成的混乱感,毕竟半透之后和背后的画面是叠加在一起呈现的,实现起来比较复杂。
基于以上原因,我们开始探索一种全自动的镜头跟随方式,系统可以根据玩家的行为,自动计算出符合玩家直觉的视角并完成调整。这样就可以让玩家在探索场景时,不需要额外操作镜头,也能持续看到主角;随着主角的移动,镜头会转向玩家想要看到的方向。这样就释放了玩家的一只手,可以设计其它更有意义的操作来丰富游戏性了。
我会详细介绍这个方案的实现,并附赠完整的实现代码。可以直接用于游戏的开发,使用时根据自己游戏的特点去配置各项参数,以调试出最佳体验。当然也可以将方案的几个处理模块拆出来单独使用:仅跟随,与摇杆配合,处理遮挡物,或者添加更多的镜头控制方案,切换使用以适应不同的场合。相关配置参数比较多,可详见附件里的Unity工程。
当然,为了照顾到更多的读者,请允许我简单介绍一下几种游戏视角及其优缺点,老手们可以直接跳过 1.2 部分。
1.2 游戏视角的介绍
第一人称视角,是以玩家的主观视角进行游戏,你能看到别人,却看不到自己的全身。优点是代入感强,缺点是有些场景下不能提供良好的视野,比如躲在障碍物背后就完全看不到对面。比如:《使命召唤》。
第二人称视角,是以玩家敌人的视角进行游戏,体验比较特殊,应用也比较少。目前,只在一些游戏的某些关卡中少量使用。
第三人称视角,是玩家以旁观者视角进行游戏,主角在游戏屏幕上是可见的,可以突出主角的动作行为。整个前方的视角非常开阔,就算是躲在障碍物后边,也可以通过边角位置观察对面的情况,受到玩家的欢迎,比如:《塞尔达传说:荒野之息》。
目前市面上的游戏主要以第三人称视角和第一人称视角为主,甚至有的游戏可以在第一人称和第三人称之间实时切换,比如:《我的世界》。
接下来我们就来具体讲讲方案的实现。
2 | 第三人称视角下全自动跟随镜头的实现
2.1 核心思想
这种方案的核心指导思想是,尽可能地通过旋转和推近来找到可以看到主角的最佳位置,并通过预先设定,完成全自动的镜头跟踪过程。
您可能会问,什么是最佳位置呢?因为它直接关乎到玩家的体验,我们首先就来定义它:在3D游戏中,玩家对于镜头的运动是比较敏感的,当运动过快或者幅度过大时,都容易造成眩晕感。那么在尽可能能看到主角的情况下,相机运动幅度越小,眩晕感也就越少,那么此时的相机位置也就是“最佳位置”了。
依着这个思路,我们把最佳体验从上到下做一个排列:
1. 相机没有任何转动或推近
2. 相机仅仅是为了避免进入模型内部而进行的推近,没有任何转动
3. 相机有一定的旋转和推近(因为结合了两个因素而可能有无数多个解,我们需要根据转动最少的策略以及限制推近的取值范围来确定唯一解)
4. 相机进行变化量几乎不受限地推近尝试直至看到主角,没有任何转动
5. 看不到主角,只能通过标志提示之类的辅助手段告知玩家主角位置
这些体验当中,1和5都是比较容易理解的,2、3、4我来详细阐述一下:
先来分析第2条,因为我们的目标是为了看到主角,而相机避免进入模型内部仅仅是一个“修正”行为,当我们在tick中逐帧处理此类“修正”行为时,大多数情况下相机的运动幅度是很小的,所以“仅仅通过修正行为就可以看到主角了”。因此这一条是有较高体验价值的。
那么第2条和第4条的区别是什么呢?从算法上来看,当第2条成立时,那么它们是等价的。但是当第2条不成立时,第4条也是可能成立的,只是这个推近已经不是“修正”不正常位置的行为了,而是为了“看到主角”而进行的推近尝试,属于“主动策略”,其幅度可能会非常大,所以第4条和第2条虽然都是纯推近,但是将这两种情况分开处理,可以让第4条这种情况作为第2条不成立后的补充策略,其价值排在其后。
再来看看第3条,前面说到第2条属于“修正”行为,而第3条与第4条一样都属于“主动策略”,都是为了看到主角而做出的主动调整相机的行为。为了要区分出第3条与第4条的体验价值高低,那么我们就要从一些具体的实例入手,这样会更容易理解一些。
我们想象在一般的游戏情况下,主角在特定的场景中移动,通常会被一些石头、木桶、树木、墙壁拐角等遮挡。当主角试图向此类遮挡物后方移动时,由于第三人称视角下镜头与遮挡物是有一段距离的,遮挡物也有一定的体积,使用第4条的策略来调整,镜头推近的距离至少是大于遮挡物体积的(往往会大得多),相对调整成本就会很大。
使用第3条的策略来调整,调整成本往往与主角从可以被看见到被遮挡时的移动量呈正相关。比如主角在墙角转弯,镜头只需要水平转动一点即可再次见到主角,而且在逐帧处理时,这种策略带来的调整成本通常都是不大的(特别是镜头与摇杆本身就已经有符合直觉的配合了,这个对于第3条策略也是有帮助的)。
由此,我们就得到了从1到5这样的体验排序,以尽可能提供更好的玩家体验。
2.2 实现方案
谈到镜头的跟踪,主要解决以下三个问题:
- 镜头对主角的跟随
- 如何能够让镜头进行符合直觉的转动
- 主角被遮掩时的处理
2.2.1 如何让镜头跟随主角
原理很简单,只要设定一个偏移量就可以了,或者最直接能够想到的就是下面这个公式:
camera.transform.position = player.transform.position + offset;
效果可以通过在update中执行代码看到。
你会立即发现两个问题:
问题1:必须要先把镜头的朝向确认好,并且不能改变
问题2:跟随过程是瞬时的,体验生硬
针对问题1,想要镜头无论在哪个朝向,都能让主角在镜头里固定位置,我们可以使用Camera的一个接口:ViewportToWorldPoint(Vector3 viewport) 。
通过viewport,可以获得指定的屏幕位置以及与相机距离的世界坐标点。利用这个接口,我们就可以根据主角的世界坐标与viewport换算后的世界坐标之间的相对位置,来找到使得两个坐标重合的相机位置。
camera.transform.position = camera.transform.position + (player.transform.position - camera.ViewportToWorldPoint(viewport));
针对问题2,获得相机新的位置之后,不直接设置到transform上,而是通过缓动算法计算后则可以让体验更为平滑。
camera.transform.position = Vector3.SmoothDamp(current_p, target_p, ref follow_current_velocity, follow_smooth_time, float.MaxValue, delta_time);
在下面的视频中,可以看到实现效果。
2.2.2 如何能够让镜头进行符合直觉的转动
这种需求,经常出现在需要通过遥感来控制主角行动的游戏中。什么是符合直觉的转动呢?我们可以思考一个问题,当我们通过遥感控制主角往左行走时,我们是否是希望看到左侧的景象?(这里并不考虑锁定敌人进行战术移动这种特定情形)答案显然是肯定的,通常情况下,角色往那边走,我们就希望看到更多的那边的景象。
这里我们将行为简单的分成:摇杆前后左右,分别调整相机的俯仰(pitch)和左右旋转(yaw)。
//当摇杆往左时,镜头也转来看向左边 var delta_yaw = 0 < joystick.x ? yaw_speed * delta_time : -yaw_speed * delta_time; //当摇杆往右时,镜头也转来看向右边 var delta_pitch = 0 < joystick.y ? -pitch_speed * delta_time : pitch_speed * delta_time;
同样,也有一些细节需要处理和优化:
调整yaw过于灵敏会导致很难进行直线的前进和后退(因为相机会自动调整yaw值)。针对这个问题,可以设置一个角度高通值,只允许摇杆指针与垂直线角度大于此值时才开始调整yaw值。
如果调整度随摇杆移动进行相对应的变化,体验会更自然一些:比如摇杆指针与垂直线夹角越大,则调整度越大,反之越小。这能让玩家体验到越是偏向左右,相机左右转动就会越快,越是偏向前后,相机左右转动会越慢,甚至是不转动(通过上面提到的角度高通值实现)。
由于镜头跟随主角的实现方式是不受镜头朝向限制的,所以响应摇杆调整镜头朝向与跟随主角能够完美地配合起来,形成比较符合直觉的体验。
在下面的视频中,可以看到实现效果。
2.2.3 主角被遮掩时的处理
在主角被遮挡时,要怎么处理呢?镜头移动过程中,陷入了模型内部,要怎么修正?
常见的做法:半透(全透)+ 推近,将场景中的物体分为两类,第一类用半透(全透)进行处理,这类物体通常较小,往往分布在行动路线上,常见的有树木,小型装饰物等;第二类则通过从主角向摄像机发射线,如果遇到此类物体阻挡,那么则将相机推进到碰撞点位置,这类物体往往较大,常见的有墙壁、大型装饰物等。
本文介绍一种不太一样的做法,这种做法不会将场景中的物体分类,而是全部都用同一种方式来处理。
在面对场景中任何遮挡物时,都用统一的一种方式进行处理,减少了场景编辑师的编辑工作,工程师无需特意实现半透(或隐藏)的效果。对玩家来说也能保持一致的体验,理解成本低。
总体的逻辑思路如下:
// 保持与主相机参数一致性 tick_camera(config, time, delta_time); // 处理摇杆对yaw和pitch的影响 var can_see = tick_joystick(config, time, delta_time); if (!can_see) { // 通过调整yaw或者pitc以及viewport_z,找到可以看到目标的位置 tick_see(config, time, delta_time); } // 根据fvp调整相机位置 tick_follow(config, time, delta_time);
这里重点说一下tick_see的逻辑:
// 1. 尝试正常的viewport var see_ret = canSee(config, source_p, target_p, out Vector3 collision_p, out float collision_distance); if (SeeResult.CAN_SEE == see_ret) { // 恢复到正常viewport_z viewport_z = 0; return; } else if (SeeResult.CAN_SEE_SINGLE == see_ret) { viewport_z = collision_distance; return; }
上面这一段代码是为了完成体验价值排序里的第一条:相机没有任何转动和推近,以及第二条:相机仅仅是为了避免进入模型内部而进行的推近。
// 找到可以看见目标的位置所需要的角度偏移值 private static float findOutCanSeeAngleOffset( CameraController config, float offset_delta, float offset_limit, System.Func<float, Vector3> vec_rotate_func, Vector3 source_p, Vector3 target_p, bool negative_rotate, out float collision_distance)
然后再通过上面这个接口,从镜头的四个不同旋转方向来找到可以看到主角的相机位置,并记录相应的旋转量和推近量,并通过下面这个计算测试值的方法,得到四个方向的镜头调整成本测试值,测试值越低,代表成本越小。这样就完成了体验价值排序里的第三条:相机有一定的旋转和推近。
// 计算选项的测试值(结合角度偏移值和zoom值,以及它们各自设定的权值) private static float calcChooseTest(CameraController config, float angle_offset, float viewport_z_offset) { var choose_test = float.MaxValue; if (360 > angle_offset) { choose_test = angle_offset * config.choose_angle_test_scale + viewport_z_offset * config.choose_viewport_z_test_scale; } return choose_test; }
完成第四条体验价值相对来说比较简单了,这里直接使用了前面的策略计算后留下的一个值即可。
// 没有任何旋转调整选项的话,如果仅zoom便可见且zoom值在设定的最小值以上,则使用zoom if (config.viewport_z_min < collision_distance) { viewport_z = collision_distance; }
至于第五条体验价值,先卖个关子,文末揭晓。
2.3 代码的适配和调整方案
根据以上操作,全自动的跟随方案就实现了。比如,读者在游戏开发中,可能会有一些不一样的需求,大家根据附赠的代码(可在第三节文末下载),可以做出符合自己的设置。
本文提供的方案代码是可以直接用于游戏的,使用时根据自己游戏的特点去配置各项参数,以调试出最佳体验。当然也可以将方案的几个处理模块拆出来单独使用:仅跟随、与摇杆配合、处理遮挡物,或者添加更多的镜头控制方案,切换使用,以适应不同的场合。
3 | 方案总结和拓展(附Demo下载)
3.1 方案总结
这个方案的逻辑和算法上没有任何艰深的东西,是一个“尽量用简单且直接的方式去实现还不错的效果”的思路践行。我一直认为做游戏,“着眼于体验”比“追求技术”更有趣一些,毕竟大多伟大的作品都不是靠“秀肌肉”来完成的。也希望大家能在这个方案里能够找到属于自己的一点点启发,这也是我写此文的初衷。
3.2 方案的拓展
读完此文,你可能会问,这种全自动的跟随方案还可以应用到哪些场景呢?
本文提供的第三人称视角下,镜头全自动跟随的方案在实际的游戏中比较适合场景探索时使用,减少玩家调整镜头的操作。
在需要特别专注的战斗时(锁敌),镜头需要持续跟随玩家的同时关注敌人,那么镜头的转动应该要去服务于这种战斗体验了,此时就应该使用不同的跟随方案;此方案还可以与玩家手动转动镜头搭配使用,在全自动与手动之间实时切换,以适应更多复杂的情况。
3.3 方案的优化
本文方案的技术处理手段采用了“不同属性分层计算策略”,将相机的“位置”与“角度”两大属性用完全不耦合的方式去控制,但是实现的方案又能配合上以达到最终的效果;不满足于此的朋友们,可以进一步挖掘。比如着眼于“玩家直觉”去设计,在泛“用户体验”设计领域都可以尝试如此思考,比如设计用户界面,设计战斗操作和反馈之类的。
在源码中,我还留下了一个TODO(其实就是第五条体验价值的实现),有兴趣的朋友可以尝试去完成,当做是一次小小的练习。
// 没有任何旋转调整选项的话,如果仅zoom便可见且zoom值在设定的最小值以上,则使用zoom,否则不可见 if (config.viewport_z_min < collision_distance) { viewport_z = collision_distance; } else { // TODO 需要处理相机陷入模型的情况(两次射线找到修正位置),同时还可以通过屏幕指示来告知玩家 Debug.Log("can't see"); }