写在前面的话(A New Start)

作为引擎组成员,看着两个项目组成功发行了两款项目,正在准备发行一款项目,下面计划中的还有两款项目。五款项目让我发现一个有意思的现象,大公司轻易不敢尝试新的技术。

我是做3D图形学研究的,更细化一些,我最擅长的是OpenGL ES,从1.1到2.0到3.0到3.1,目前最新的是3.2。OpenGL ES还是发生了比较大的变化。2013年的时候,我在Marvell的MBU(mobile business unit)实现OpenGL ES3.0,从手机解决方案作出,到产品化,到进入市场,iphone5s那一代手机(2013年)已经开始支持了OpenGL ES3.0。到今天已经四年了。我觉得作为游戏厂商应该准备全面拥抱OpenGL ES3.0了,据说网易已经这么做的。

OpenGL ES3.0有很多新的特性,最常用的GPU Instance等,这个我会找个时间在OpenGL ES串讲中一一说明。

然而正如我所说,大公司不太敢轻易用这些技术,即使我敢打百分之99的包票(毕竟我不知道Unity内部会不会有bug)。

那么好吧,我决定自己写个有意思的demo,这个demo可能玩法不行,但是我尽量用一些最新、最靠谱的技术来实现,然后尽量给出技术分析。

刚好,接到任务让我写一个游戏框架,那么就把两个事情合二为一,开始写吧。

创建一个新的场景

创建一个新的场景后,有很多事情要做,首先,把场景中的物件删除,只留下一个摄像机。毕竟一切从零开始嘛。这个摄像机作为Main Camera,不负责UI和Cutscene。关闭OC、HDR(一般只有使用PBR才打开HDR)、MSAA(性能考虑)。

然后调整光照方案,Window->Lighting->setting。一样的全部删除,天空盒、环境光(对应shader中的UNITY_LIGHTMODEL_AMBIENT)、环境反射(间接光)、实时GI、混合GI(含lightmap)、雾、Halo光晕、Flare耀斑等。把这些全部关掉,等用到的时候再一个个加上,到时候再一个个解释。

Unity酱镇楼

准备先做个场景让角色动起来,使用Unity提供的资源做Demo,当美术做好资源后会以FBX的形式给程序,那么在这里我们直接去看FBX文件,先看其import settings

Models

  • Scale Factor/Use File Scale:Unity的物理世界为1米一单位,这个是用于调整美术工具和Unity中单位不一样的情况。
  • Mesh Compression:压缩mesh尺寸,会导致变形,理论上在mesh效果可以接受的情况下,尽可能使用高的压缩比,但是由于一般Mesh占内存不会太大,所以一般不会去压缩。
  • Read/Write Enabled:打开的话Mesh数据会保留在内存中,不打开的话Mesh数据会被unload。除非特殊需要,一般都会关闭的。
  • Optimize Mesh:优化Mesh,一般都会打开,除非遇到什么问题。也不知道Unity内部怎么实现的,尴尬的闭源引擎。
  • Import Blendshapes:Blendshapes是什么鬼
  • Generate Colliders:用Unity写游戏一般都是开发者自己负责物理部分,所以Unity中所有物理有关的全部关闭。
  • Keep Quads:关闭的话,所有的四个顶点以上的多边形都会转为三角形。Tessellation shader比较倾向使用Quads。然而OpenGL ES3.1才开始支持Tessellation shader,所以果断关闭。
  • Weld Vertices:将相同位置的顶点合并,这样可以减少mesh的顶点数,一般打开。除非是特殊情况需要在同一个位置使用两个顶点保存数据,比如软边
  • Swap UVs:lightmap相关,用于交换UV和UV2的。关闭。
  • Generate Lightmap UVs:lightmap相关,打开后会对Lightmap生成uv2。先关闭
  • Normals:一般mesh都会有normal信息,所以导入即可,即使没有实时光、不计算lightmap,normal也很重要,比如计算边光之类的。
  • Smoothing Angle:调整边的角度,用于控制硬边、软边
  • Tangents:一般都是由normal计算而得,更多情况是不需要tangent,因为只有使用法线贴图的时候才需要tangent信息。
  • Import Materials:一般都是关闭,因为3D Model的材质一般都是自己写,默认材质没人用。。

Rig

为skin mesh创建绑定一个avatar,这样就可以使得skin mesh动起来了。

Animation Type:

  • 如果skin mesh是人,那么选择humanoid character,然后创建avatar即可。或者选择一个已经创建好的avatar。
  • 如果skin mesh不是人形,动作系统使用新的Mecanim系统,那么选择generic,不过这样的话就需要选择一个骨骼作为root node。
  • 如果使用老的动作系统,那么选择legacy。

Optimize Game Object:打开后,骨骼的transform信息除非特殊指出,否则都会被记录在avatar和animator组件中。在打包之前要打开。

Animations

  • Import Animation:选择是否导入animation。
  • Bake Animations:使用IK或者simulation制作的AnimationClip,可以通过bake的方式生成运动学的keyframe。
  • 当使用generic(非人形动作)的时候,可以打开这个选项使得动画曲线会在每帧重新采样。针对原始的每两帧之间的插值动画曲线有问题的时候打开。关闭的话就会按照原始的动画曲线。
  • Anim Compression:压缩动作文件,会根据开发者设置的误差允许范围,减少关键帧。
  • Clips:可以将导入的动作拆分成若干个Animation Clip,这些AnimationClip将会在Unity中被真正的使用,需要设置好起始点和终点。

将Unity酱拖入场景动起来

拖进场景中的Unity酱小姐姐是黑色的,原因也很简单,此时的Unity酱用的是默认材质,默认材质用的是PBS,虽然一会我们还要给Unity酱换衣服(替换材质),但是现在我们还是加个光源吧。拖一个平行光进来。Unity酱变白了。。。

创建一个Animator Controller,将需要用到的AnimationClip拖进去,选择一个idle状态作为初始状态,然后创建parameter,作为状态转变的条件。然后把Animator拖给场景中Unity酱的Animator组件。

Animator

  • Animator Controller:Animator组件是用于给场景中GameObject赋予动作的。Animator组件必须要关联一个Animator Controller,用于确定使用哪些Animation clip,以及何时、以何种方式在不同动作之间做切换。
  • Avatar:如果GameObject是带有Avatar的人形,那么需要将Avatar赋予给Animator。
  • Apply Root Motion:这是个比较有意思的功能。如果勾选了,那么角色的Transform和Rotation将不能通过脚本来直接赋值,而是通过动画的运动的来改变的。如果不勾选,就可以用脚本改变角色的Tranform。我一般先勾上,比如有个技能需要加速前进,那么在动画里面可以做好这个加速的效果,如果使用脚本的话,就需要根据我播放的是什么动画,改变角色的移动速度。再比如碰撞等等,这个选项关联的东西很多,以后估计会经常想到它。
  • Update Mode:默认为Normal,就是正常播放动作,如果游戏暂停或者加速/减速,动作也会相应的变化。相反就是Unscaled Time,也就是当游戏暂停或者加速/减速,动作不会受到影响(比如UI的动作,游戏主逻辑暂停,UI也不应该受到影响)。最后还一种模式Animate Physics,这个就厉害了,这个选项只有在结合运动学刚体的时候才有用。我在UE4里面做过一些这个,就是给物体一个物理属性,在这里就不展开说了,一般用不到。
  • Culling Mode:决定了物件如果不在摄像机范围内是否还运动了,Always Animate就是一直运动,默认使用这个。Cull Update Transforms,当物体不在摄像机范围会停止部分动作处理,比如IK。Cull Completely,当物体不在摄像机范围内会停止全部动作,但是有个风险,在开启 RootMotion 时,如果人物有一个巡逻动画是通过 RootMotion 制作的,那么在人物走出屏幕后,其动画就停止了,就不会再走回屏幕中。在这个Demo中我们挑战一下,使用Cull Completely。
  • Animation curve information:Animator组件下面的Box中填写的是当前Animator中所有Clip的信息。

点击运行,OK,Unity酱小姐姐进入Idle状态了。

使用摇杆让Unity酱跑起来

所谓的摇杆就是在屏幕的指定区域触摸屏幕,然后再根据触摸的位置配上一个摇杆的UI。

通过Input的GetMouseButtonUp和GetMouseButtonDown判断触屏动作,然后通过Input的mousePosition判断手指移动的位移。加上一系列的逻辑运算,得到角色是否移动,以及移动的方向和尺度。当确认角色移动的时候,通过Animator的SetTrigger函数,触发在Animator中设置的角色状态转变。

点击播放,idle没问题,但是触发移动的时候,有延迟,保持idle状态几秒之后才播放跑步动作,原因是状态切换的时候,无法打断上次动作,必须要上次动作播放完毕才能播放下面的动作。下面具体研究一下Animator Controller和AnimationClip组件

AnimationClip

AnimationClip可以从第三方工具导入,或者在Unity中制作。

如果在Unity内部制作Animation Clip

  • 可以改变GO的position、transform、scale
  • 组件属性,比如材质颜色、灯光亮度、声音大小
  • 自定义脚本中的属性
  • 脚本函数的执行时间

如果从第三方工具导入Animation Clip,Clip-specific properties

  • Source Take:当FBX文件中包含多个动作文件时,会让开发者选择使用哪个文件。一般一个FBX只有一个动作文件,然后根据这个动作文件拆分成一个或者多个Clip。
  • Loop Time:开启AnimationClip的循环模式。
  • Loop Pose:使得循环更平滑一些,一般不用打开。
  • Cycle Offset:控制循环的时候起始帧偏移用。一般也不打开。
  • Root Transform xxx:打开的话就会将跟骨骼的变化只应用在骨骼上,不会应用到播放动画的主体对象上。关闭的话将跟骨骼的变化作为root motion,动画产生的旋转或位移(具体看是哪个选项下面)会应用到播放动画的主体对象上。
  • Based Upon:基准点,有如下类型:Original:表示基准点使用在动画文件中预设好的值。Center of Mass:表示基准点使用质量中心,表现为人物下半身会嵌入地面。Feet:表示基准点使用第一帧的脚。
  • Mirror:左右对称,只针对humanoid character(人形)。
  • Additive Reference Pose:设置一个reference pose。

Animator Controller

打开Animator Controlller后,将Animation Clip拖进去,每个Animation Clip会变成一个Animation State,存在于Animation State Machine中。当游戏中触发了一次状态切换,角色就会切换状态,然后播放该状态下的Animation Clip。

Animation State:

  • Motion:这个Animation State绑定的Animation Clip。(将Animation Clip拖进Animator Controller之后,可以将生成的Animation State改名,只需要保证这里Motion的name对应的是指定的Animation Clip即可。)
  • Speed:动作播放的速率。1表示正常速度,后面的Parameter勾上表示使用一个参数来表示当前的速度,同时输入框会变为下拉选择框,我们可以选择指定的参数,参数可以在Parameters面板中配置,其作用就是可以方便的通过代码修改参数的值来达到控制速度的目的,下面的Parameter也一致,就不赘述了。
  • Mirror:是否将动画沿Y轴进行翻转,一般用来复用动画,比如招右手的动画勾选了此项就会变为招左手。
  • Cycle Offset:播放偏移量。
  • Foot IK:针对humanoid character(人形)的时候,该state是否需要打开IK(反向动力学)。(目前我还没见过哪个手游使用IK的,之前做主机游戏的时候经常使用IK,在需要脚部贴合地面的情况时可以开启)
  • Write Defaults:动画播放完毕后是否将状态重置为默认状态,一般勾选即可。
  • Transitions:下面的Transitions面板则会列出当前状态可以过渡到其它状态的列表:Solo:勾选表示当前过渡为唯一过渡,即当前状态只能过渡到这个项目指向的状态。(如果不选择solo,在没有变量控制(结束条件为“exit time”)的情况下,该状态优先选择动作列表中最前(或者说最上的)的状态转移;如果选择了某个solo,那么在没有变量控制(结束条件为“exit time”)的情况下,优先选择标记solo的状态转移;如果有多个状态转移选中了solo,那么优先选择这些已选中solo的状态转移中,在动作列表中靠前的状态转移)。Mute:勾选表示使这个动画过渡关闭,即当前状态不能过渡到这个项目指向的状态。如果一个状态被标记为solo,那么其余的状态转移将被视为选中mute。如果solo和mute同时被选中,那么mute的优先级更高(即视为只选中了mute)
  • Default State:橙色的状态为默认状态,也就是第一个被触发的状态,如果想换一个状态作为默认状态的话,点击右键选择Set As Default即可。
  • Any State:假如有一个状态可以转自任何一个状态,那么就可以从Any State拖一根线过去。代表着任何状态下,都可以通过一定的条件触发这个状态。(不用从每个状态拖根线到这个状态了)。但是不能拖一根线到Any State。

Animation transitions,定义了状态转换的时间和条件。ransitions和state一样都可以定义一个名字,这个名字将出现在state的transitions中。同一时间只能有一个transition是被激活的,当然如果允许的话,transitions是可以被另外一个transitions打断的。

  • Has Exit Time:如果勾选了该项,在动画转换时会等待当前动画播放完毕才会转换到下一个动画,如果当前动画是循环动画会等待本次播放完毕时转换,所以对于需要立即转换动画的情况时记得要取消勾选。还有一种情况时,如果当前的动画播放完毕后就自动转换到箭头所指的下一个状态(没有其他跳转条件),此时必须勾选该选项,否则动画播放完毕后就会卡在最后一帧,如果是循环动画就会一直循环播放。(解决了Unity酱小姐姐触发移动的时候有延迟的问题)
  • Exit Time:当Has Exit Time打开的时候有用。设置当上一状态播放到什么程度的时候,切换到下一状态。比如0.75意思就是上个状态播放到0.75,则触发状态转换。一般用不到,都是使用条件触发状态转换。
  • Fixed Duration:勾选的话,状态转换的时间(Transition Duration)就以秒为计算单位。不勾选的话,时间就以上一个状态时间的百分比计算。
  • Transition Offset:下一个状态开始播放的offset。比如0.5的话,下一个状态就从动作一半处开始播放。
  • Interruption Source:控制的是动画状态切换时的打断源。
  • Conditions:状态转换的条件,如果开启了Has Exit Time,需要同时满足状态转换条件和达到了Exit Time。

OK,Unity酱小姐姐终于可以快乐的奔跑了。

等一下。。。小姐姐只是跑起来了,但是。。没有(移)动。。

UI坐标转世界坐标

有了动作,再加上模型位移,看上去就是在跑步了。动作有了,加上位移即可,位移很简单,就是改变物体在世界坐标系中的位置。

在这里将视角先转为流行的2.5D构图,45度视角,也就是摄像机从角色的45度角俯视拍摄。

拖动一下,沿着世界坐标系的-z轴移动为前进,+z轴移动为后退,-X轴为右,X轴为左,Y为垂直轴。

下面要做的事情就是将触屏得到的手指移动矢量从屏幕空间转换到世界空间即可,然后通过transform.position改变(position为该物件在世界坐标系中的位置,localPosition为该物件在父节点坐标系中的位置,然而当前父节点坐标系就是世界坐标系。坐标转换需要图形学+数学知识,这里就不展开说了,好奇的话还是去补习一下吧,这里省去一万字)

位移解决了,那么人的朝向一样需要根据手指的移动矢量来使得模型绕模型坐标系的Y轴旋转,通过transform.eulerAngles改变(同上,eulerAngles为为该物件在世界坐标系中的rotation,localEulerAngles为该物件在父节点坐标系中的rotation,然而当前父节点坐标系就是世界坐标系)。。

OK,最终,小姐姐在屏幕上开心的跑起来了。

相机要跟着焦点跑

小姐姐跑出屏幕咋办,那就不让她跑出屏幕。让相机时刻跟着小姐姐移动。

这个实现起来也很简单,刚才已经根据触屏的矢量对Unity酱在世界坐标系下进行移动了,那么只要让相机跟Unity酱的移动同步,保持相对静止即可。做法也就是让相机跟着Unity酱的移动而移动即可。

给Unity酱规定个活动区域

无规矩不成方圆,跑着跑着发现小姐姐可以穿墙了。那么,需要给Unity酱设置一个活动区域。

正规办法是建立一个物理世界,给每个物件(包括Unity酱)都创建一个碰撞体,然后物理世界中每帧都会计算碰撞体的位置,根据设置的碰撞原则,更新碰撞体下一帧的位置。然而,由于碰撞体之间的计算量很大,所以手游很少会这么做。

在这里使用一种黑白图的方式,当游戏不存在“桥洞”问题(即桥上桥下都能走人,两层)的时候,一张2D的黑白图完全可以满足人物活动范围的问题,以上帝视角,俯视角给全场景拍张照片,然后美术扣出一张黑白图,黑色为禁止活动区域,白色为可以活动区域。然后,当人物创建的时候,同时定位到它在这个黑白图上的坐标,然后随着人物移动,也在黑白图中移动,当在黑白图采样发现当前帧要走到黑色区域了,则给人物一个反馈,无法往前走了,也就是当前帧移动失败。

黑白图的白色区域还可以保存高度信息,也就是记录该区的高度,这样,在判断人物当前帧是否会被阻拦的时候,也同时计算出当前帧的高度,如果不会被阻拦的话,更新人物移动位置的时候,同时更新人物高度。实现比如爬坡的效果。

先实现黑白图

原则上黑白图是以俯视角拍张照片后拿给美术去抠图的,不过其实也可以把阻拦物件全部绘制成黑色,然后拍一张照片保存在RT中,读取RT中的数据如果不是黑色,则修改为白色,然后保存成一张PNG图片(其实最好是BMP图片,不过Unity做BMP图片还需要导入库,那么PNG图片也一样,因为PNG是无损压缩,不可以做JPG图片,因为JPG是有损压缩)。

图片的大小以及相机的size决定了黑白图的精度,这里相机必须是正交相机以俯视角拍摄。1个size为2个unit,也就是2米,如果相机的size设置为10,那么也代表着这个黑白图表示的是20米的区域,如果黑白图的RT设置为256*256,那么也就是说256个像素=20米,1个像素等于大概0.1米,这样精度就已经很高了。一般RT不会搞太大,因为毕竟还是会占内存的(虽然可以通过修改图片格式解决)。一般1个像素等于0.5米左右就可以了。

这个工作是在Editor里面操作,所以对性能要求不高。

读取黑白图

黑白图创建好之后,读取就很简单了,只需要注意一点读取纹理图片是按行读取的,而PC上Y0点在上方,手机上Y0点在下方,所以会出现平台差异,其他都很简单。读取出来后,判断当前帧将要移动的位置如果为黑色,则无法移动(旋转不影响),如果为白色则可以正常行走。

制作高度图

其实高度图在刚在用摄像机拍照的时候,搞一张含DepthTexture的RT拍照,高度图直接就存在了RT的depthbuffer中。但是由于Unity无法读取DepthBuffer,而我们又必须得到depth buffer的值,所以尴尬了,我目前是用一个最原始的办法,从相机位置的水平面垂直往下发射射线,射线求交得到地形的高度信息。得到的高度信息先缓存成一个0-1之间的数,然后保存在PNG图片中(会有精度问题,比如我假设场景高度都在-2至2之间,那么我得到高度后加上2除以4,得到一个0-1之间的数,将其保存在图片的某个通道,由于图片的某个通道是由若干bit组成,比如8bit,那么只能代表256种高度,虽然一般是够了,但是确实会有精度问题)。

PS:发现Unity的一个bug,如果两个mesh比较近,Physics.RaycastNonAlloc射线求交算法有点算不清。另外该算法的layermask参数是从1开始,如果通过LayerMask.NameToLayer获取layer的层级,则需要左移1位。(1<

读取高度图

得到高度图之后,在计算人物位移的时候,如果确定人物不被block,则获取该点高度图的大小,加到人物位移上即可。