Unity3D学习笔记

Unity3D学习笔记

本笔记是学习自夏村散人韩老师的unity教程https://www.bilibili.com/video/BV1B7411L74W,自己的一些记录和整理

一、基础

1.1 unity编辑器

右边栏是组件栏,物品、地形、相机等全都是对象GameObject,每个GameObject有自己的组件component,基本上所有物体都包含transform组件用于缩放和旋转。地形,相机等特殊物体可能有自己特殊的组件。

image-20210727232539699

1.2 地形系统

创建地形

image-20210727232606840

笔刷(依次是抬高降低、空洞、纹理、平台高度、平滑)

image-20210727232631047

纹理

image-20210727232649945

1.3 标准资源包

18年后的版本需要自己下载,可以在官网下载

[https://assetstore.unity.com/top-assets/top-download?q=Standard%20Assets&orderBy=1](https://assetstore.unity.com/top-assets/top-download?q=Standard Assets&orderBy=1)

也可以直接在编辑器windows->assets store

报错可以参考这个教程

https://blog.csdn.net/zhang2333333333/article/details/105000621

1.4 脚本

脚本也作为一种component,可以添加c#脚本,其中自带的start和update函数就是生命周期,会自动调用,其中start在GameObject初始化时调用一次,update每帧调用一次。public成员可以在脚本完成后通过unity编辑器拖拽给物体作为参数传入。

下面介绍一些脚本相关的类(组件):

Transform:可以对应一个unity中的GameObject

具体的类和方法可以看帮助

image-20210727232744949

点击后打开api页面可以搜索

其中,manual是以教程的形式,api只是单纯的用法和参数介绍

image-20210727232802828

如果没有代码提示,是由于默认编辑器没有设置的原因,参考

https://blog.csdn.net/LiYAnErr/article/details/105137157

以下是一个镜头跟随脚本demo

image-20210727232818146

1.5 发布

image-20210727232834682

若要选择窗口等模式,如下:

image-20210727232846787

1.5.1制作游戏欢迎页面

1.unity中一个页面scene其实就是一个关卡,因此欢迎页可以视为一个关卡,该关卡啥都没有,只有一个脚本,按下某个键就开始游戏

image-20210727232859810

scene名字随便起,比如叫welcome

2.脚本

脚本名字不能随便起,叫GameManager(也不是不能随便起,只不过叫这个的话可以拥有一个特殊的图标)

image-20210727234307844

image-20210727234316957

3.将脚本应用到物体上,理论上可以应用给场景默认自带的光照或者相机都可以,但是标准的应该是在该场景新建一个空的GameObject,并将脚本拖拽给他

4.调整发布场景编号

image-20210727234331763

1.5.2 非全屏

image-20210727234346299

二、动画

2.1 场景动画

unity支持外部动画模型导入(比如maya和3dmax),成为模型动画。当然也可以在unity中制作简单的动画,称为场景动画

2.1.1 录制

image-20210727234419574

选择动画片段保存位置

点击录制按钮

image-20210727234426467

2.1.2 多段动画的状态机转换

可以添加多个动画片段

image-20210727234434960

添加多个动画片段后,默认只会播放第一个

如果需要组织多个动画片段,就需要animator controller

image-20210727234443887

调整状态机使其能够转换到另一个动画片段

image-20210727234454641

效果就是动画连着播放,然后在最后一个动画循环

除了鼠标操作状态机,也可以通过脚本进行特定条件下状态机的转换,如下创建一个转换条件c

有Float, int, bool, trigger类型,最后一个是触发器,也就是脉冲,触发完一次会自动回到初始状态等待下一次脉冲

image-20210727234503220

然后添加一个脚本,指定触发条件

image-20210727234511472

将脚本拖拽给cube,即可自动创建出animator controller这个组件

效果:停在第二个动画反复循环,按下空格(并当当前动画放完之后)才进入第三个动画

若需要按下空格时强制跳转到第三个动画,如下取消勾选

image-20210727234531401

2.1.3 event

event事件,用于在某个特定时间点执行某个脚本中的函数

首先添加一个函数,这里是销毁,我们的目的是在最后一个动画结束后销毁

image-20210727234541537

然后在最后一段动画(缩放的某个地方添加一个event),填入函数名

image-20210727234551815

2.2 骨骼动画

2.2.1 space robot kyle资源包和配置

unity官方提供的一个资源包,可以在商店搜索下载

将rig调整为类人骨架humannoid,记得点击apply

image-20210727234604507

这个骨架有个好处是可以容易的实现动画重定向(为一个骨架设计的动画可以移植到另一个骨架上),点击apply后configure按钮亮起,点击可以看到如下界面

image-20210727234614346

这只是一个模型,拉取到场景中没有动画效果,我们可以从标准资源包如下位置拉取一个预实现看看效果

image-20210727234630166

2.2.2 骨骼动画交互效果说明

对比我们可以发现预实现的人物有animation controller

image-20210727234638215

我们可以为自己的机器人模型也添加同样的controller

人物随之可以摆动,但没有键盘交互效果

不难发现,预实现的人物对象中还有一些脚本

image-20210727234646245

第一个脚本有[RequireComponent(typeof (ThirdPersonCharacter))],也就是包含了第二个脚本

所以我们只需要把第一个脚本拖拽给自己的模型

image-20210727234653365

我们通过右边的capsule将框架调整到如上图所示大约包裹身体,此时运行如下图所示

image-20210727234703313

这是由于地面检测原因,将其调大即可

image-20210727234714600

2.3 曲线

曲线是用函数的形式让时间和某个变量关联起来,即value = func(time),func可以是二次函数或者别的函数,value的值便会发生变化,可以用这个值影响动画

比如:机器人在跑步的时候如果想让他吹一个泡泡,泡泡随时间变大变小

新建一个场景,添加机器人,新建一个animation controller,新建一个空状态,为状态添加一个人物跑步动作

image-20210727234724312

并拖拽给机器人,机器人可以跑步

为了更好的演示,将机器人取消连接根节点(应该就是不转换为世界坐标),便可以原地跑步

image-20210727234731793

接下来添加曲线,在controller的parameters中添加参数,参数名很重要,然后点击motion,unity会自动定位这个run动作的位置,点击它

image-20210727234754151

点击edit便可以编辑属性值

image-20210727234807442

找到曲线,添加,

曲线的名字必须跟parameters中某个变量的名字一样,点击曲线并选择一种曲线

image-20210727234816468

选完后一定要点击一下下面的apply

下面来创建泡泡,新建一个GameObject命名为泡泡,并作为头部的子物体

image-20210727234828596

点击transform中的reset,球体便会将本地坐标变为头部位置(和头部重合)

image-20210727234839962

再将泡泡细微调整以下

image-20210727234847779

新建脚本,设置机器人的动作(其实是设置机器人里面泡泡的动作)

image-20210727234858007

这里的bubble当然也可以直接设置为public,然后再unity中直接将泡泡拖拽赋值。因为这个脚本最终会拖拽给机器人,泡泡是机器人的子物体,所以可以设置为private然后通过transform的Find函数获取子物体。

完事,运行,可以看到机器人跑步的过程中泡泡变大变小

2.4 动画层

动画层就是多个动画叠加,能同时展现,比如边走路边开枪

2.4.1 动画层的添加

新建场景,添加机器人,新建animition controller,添加新状态,添加走路动作

默认有个动画层,现在新建一个层比如叫ArmUp 抬手

image-20210727234912509

新建状态Jump,添加motion JumpUp

image-20210727234920061

运行发现并没有叠加动画,因为第二个动画没有添加权重

image-20210727234928179

需要注意的是,blending采用的是override模式,所以ArmUp权重越大,手抬得越高,但是BaseLayer中的walk速度变慢了!!!

而如果采用addtive,则是叠加,整个人都被抬起来,效果就会变得走起路来一震一震的

2.4.2 遮罩mask

如上所说,如果要某一部分不受影响怎么做呢?手臂抬起来,但是同时也要正常走路,就需要mask遮罩

新建avatar mask,并打开

image-20210727235011384

如下图所示将下半身的活动屏蔽

image-20210727235023829

为该层的动作选择一层遮罩

image-20210727235032104

这时候运行,就可以看到手抬到了最高(权重为1),walk也正常走路

image-20210727235051059

2.4.3同步Sync

Sync按钮的意思是当前层不使用自己的动画状态机,而是用别的层已有的动画状态机。举个例子:走路,如果人物被击中那么走路的姿势就不太一样了,而其他的逻辑一样(如果其他逻辑比较复杂),那么就可以在其他层使用Sync,方便逻辑的复用

2.4.2 脚本方式叠加层

很多时候需要通过脚本的方式完成更加复杂的动画层逻辑,比如和鼠标交互

下面示例是按下鼠标左键,增加armUp的权重,最后增加到1

新建脚本

image-20210727235104723

将脚本拖拽给机器人,并将一开始的ArmUp层权重设置为0,运行就可以看见效果了

一开始手放下,鼠标长按慢慢抬起,松开又慢慢放下

2.5 逆向运动学:注释动画

前面的场景动画和骨骼动画都属于前向运动学,也就是说每一个骨骼节点的动画效果其实是一层一层推出来的,比如抬起股关节,再抬起膝关节那么就表现为抬腿动作。

但有些情况下需要逆向推导,比如已知前面有个苹果,手指要碰到,那么肘关节和肩关节怎么转才显得比较自然呢?又或者已知一个方位(比如看像鼠标移动的位置),那么逆推头和腰的转向。这种,依靠终端节点(指关节)逆向推演其他节点如何配置的过程就叫做逆向运动学(IK,全称inverse kinematics)

下面的示例就展示机器人的注视点跟随鼠标移动

新建场景、animator controller,添加状态和motion为idle(一种悠闲自然站立,身体轻轻摇动的动作)并拖拽给机器人,新建脚本

image-20210727235125029

1
2
3
4
5
6
7
8
9
10
11
12
void OnAnimatorIK()
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); // 以MainCamera为起点,mousePosition为方向
Plane plane = new Plane(Vector3.up, transform.position); // 以机器人的中心为平面上一点,(0,1,0)为法线做一个水平面
float enter = 0.0f; // 距离
if (plane.Raycast(ray, out enter)) // 平面的方法:计算交点,并将交点和ray原点的距离赋值给enter变量
{
Vector3 target = ray.GetPoint(enter); // 射线的方法:从原点走enter的距离的点
anim.SetLookAtPosition(target); // 和相机LookAt不同,是anim中的方法
anim.SetLookAtWeight(0.3f);
}
}

在动画层勾选IK Pass,表示允许使用IK方式确定角色姿态

image-20210727235153038

效果:机器人目光跟随鼠标,实际上是相机和鼠标形成的射线和机器人水平面的交点

image-20210727235206717

SetLookAtWeight这个函数可以有多个参数,使得身体,头,眼睛都运动起来

anim.SetLookAtWeight(0.5f, 0.3f, 0.6f, 0.2f);

image-20210727235217929

2.6 逆向运动学:末端节点动画

前面提到,除了注视动画,还有逆向计算手触碰物体时手肘等节点的动画,当然脚也一样。这就是另一种:末端节点动画。

脚本如下

image-20210727235227527

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(Animator))]
public class IK2 : MonoBehaviour
{

private Animator animator;
public Transform target; // 手或者其他末端节点需要触碰到的位置
public Transform hint; // 参考位置:表示手不够长时,需要肘部也改变,肘部节点触碰到的位置
public bool isHand = true; // 我们做一个手和脚的,这个变量判断是手还是脚

// Start is called before the first frame update
void Start()
{
animator = GetComponent<Animator>();
}

private void OnAnimatorIK(int layerIndex)
{
AvatarIKGoal g = isHand ? AvatarIKGoal.RightHand : AvatarIKGoal.RightFoot; // 末端节点
AvatarIKHint h = isHand ? AvatarIKHint.RightElbow : AvatarIKHint.RightKnee; // 参考节点
// 末端节点信息:其中的权重表示:是完全触碰目标点还是更多的照顾姿态的自然度
animator.SetIKPositionWeight(g, 1f); //全部交给ik控制则1,否则在0-1之间
animator.SetIKPosition(g, target.position);
animator.SetIKRotationWeight(g, 1f);
animator.SetIKRotation(g, target.rotation);

// 参考节点信息
animator.SetIKHintPositionWeight(h, 1f);
animator.SetIKHintPosition(h, hint.position); ;
}

// Update is called once per frame
void Update()
{

}
}

新建一个target小球

image-20210727235310158

image-20210727235319062

为其制作小球掉落再弹起的动画

新建空GameObject作为辅助结点的位置(手臂,膝盖的位置)并放入机器人内部

image-20210727235329022

运行

image-20210727235340732

image-20210727235346245

(脚朝内是小球的方向问题,将小球绕y轴转180即可)

点击isHand切换成手也类似

image-20210727235400082

image-20210727235408612

可以看出hint节点的位置就是中间节点(手肘,膝盖)触碰的位置

2.7 子状态

我们已知animator controller可以新建一个状态,unity支持新建子状态,一个状态包含多个子状态,也就是将这几个子状态封装成一个整体。比如有一个状态是 idel->walk->run->idel,也就是将这几个动作封装成一个状态。

2.7.1 创建子状态

新建场景,放置机器人,新建animation controller,新建状态,新建子状态

image-20210727235423773

让idle拥有motion为humannoid idle,make transtion使得idle指向子状态,双击子状态

发现又是一个类似的controller界面

image-20210727235434507

创建walk,run,jump动作并连线,最后指向base layer的idle动作

image-20210727235446200

这样就完成了类似一个函数栈的效果,返回base layer就可以看到下面的效果

image-20210727235458083

将这个controller拖拽给机器人,并使得原地运动,运行

就可以看到一连执行了一串动画,你可能说不用子状态也行,直接全部写外面,当然可以,又不是编程新手了,封不封装都可以的,如果觉得某几个动作都是固定连续发生,可以视为一个动作,就封装吧

2.7.2 带条件转换

当然也可以指定触发条件

添加trigger类型的参数

image-20210727235522117

新建脚本

image-20210727235528975

1
2
3
4
5
6
void Update()
{
if (Input.GetKeyUp(KeyCode.Space)){
GetComponent<Animator>().SetTrigger("start");
}
}

将脚本挂接给机器人,运行,默认idle,只有按下空格才依次进入:walk->run->jumpup

同样的可以选择立即触发

image-20210727235550077

2.8 blend tree

混合树,和transition类似,可以在多个动画片段之间进行叠加和转换,比如走路过渡到跑步。区别在于transition是一个状态到另一个状态,blend tree可以多个状态作用于角色,各动画可以有自己的权重,然后进行叠加。

2.8.1 创建blend tree

新建场景,添加机器人,新建Animator controller,创建BlendTree状态

image-20210727235602996

双击打开,对BlendTree右键可以新建motion,这里建3个

image-20210727235612097

添加motion如下:分别是猫腰走(蹲走),正常走和跑步,同时这里修改了参数名称

image-20210727235625545

在右上角可以看到blend默认是一维的1D,将controller拖拽到机器人身上,运行,运行过程中可以不断调整Speed,就可以发现在三个动作之间平滑的转换动作了。

image-20210727235632951

因此1D的混合树实际效果和transition差不多,只是可能更直观一些。

2.8.2 direct类型

下面我们将1D改成Direct

image-20210727235642006

从每个动作右边栏的变化可以看出,Direct是可以为每个动作添加自己的权重的,我们可以建立3个参数分别赋值给三个动作

image-20210727235650035

运行,我们可以在运行的过程中调整各自到合适的权重,运行起来有点奇怪,因为是叠加

image-20210727235659640

我们可以对它归一化

image-20210727235707410

这样我们就可以自由组合出:猫腰跑,慢跑(或者更像竞走)之类的动作

image-20210727235714724

2.8.3 二维笛卡尔坐标

是以x y轴的二维布点方式,点和点中间的空白通过插值进行。

这里我们xy轴设置的好,可以当成俯视图来进行布点,因此轴的意义定义很重要

image-20210727235732217

运行并调参,向左向右跑步或者走路

image-20210727235737611

简单的做一个脚本进行交互

image-20210727235746264

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityStandardAssets.CrossPlatformInput;

[RequireComponent(typeof(Animator))]
public class BlendTree : MonoBehaviour
{
Animator anim = null;
// Start is called before the first frame update
void Start()
{
anim = GetComponent<Animator>();
}

// Update is called once per frame
void Update()
{
// 跨平台的wsad控制
float h = CrossPlatformInputManager.GetAxis("Horizontal");
float v = CrossPlatformInputManager.GetAxis("Vertical");
Vector3 move = v * Vector3.forward + h * Vector3.right;

if (Input.GetKey(KeyCode.LeftShift)) move.z *= 0.5f; // 当按下左shift键时从跑步变为走路,变慢

float turn = move.x;
float forward = move.z;
anim.SetFloat("Speed", forward, 0.1f, Time.deltaTime);
anim.SetFloat("turn", turn, 0.1f, Time.deltaTime);
}
}

拖拽给机器人,运行,按下wad,和左shift,自己体验吧

3. 图形渲染

3.1 全局光照明效果

光照分为方向光(新建场景时默认那个太阳就是方向光),点光源,聚光灯和面积光

前三种都属于直接光源,也就是对物体表面产生了直接的影响,用于实时光照,计算量小

为了渲染出更加真实的效果,面积光可以进行预计算,这种计算量很大,通常用于静态渲染,因为物体移动了就会破坏预计算的效果。

当我们创建一种光照的时候,是没有阴影的,如果需要阴影可以自己打开

image-20210727235812635

soft会模糊阴影的边缘。

对于预计算的光照,结果会被烘焙成light map保存,在window->rendering->lighting中可以看到auto general这个框框,如果打上,会对场景中勾选了static的物体进行自动烘焙,当然,为了开发时的性能,可以关闭,在发布之前进行一次烘焙。因此,对于不动的物体,我们最好设置为static,可以得到光照效果的优化。

image-20210727235820794

如下图,可以看到设置成static后,绿的的物体的阴影有一点点的绿色,其他物体也一样,更加富有表现力,如果没有static的物体,是不参与这种晕染的

image-20210727235831205

3.2 材质,着色器和纹理

光影计算中很重要的三个概念:material,shader,texture

新建一个材质,可以看见材质中包含了shader和uv纹理贴图等各种信息

image-20210727235838928

着色器:像素着色器是负责渲染材质颜色的程序片段,顶点着色器就负责材质表面纹理,法线贴图(粗糙程度),反射率等东西。默认是标准

rendering mode:Opaque是不透明,cutout可以选透明度,fade是消隐可以实现淡入淡出,transparent是透明可以设置透明通道

albedo是基本的颜色,点击圆圈可以选纹理

image-20210727235848225

cutout透明,可以调节透明度

image-20210727235856496

fade,可以根据alpha通道(RGBA中的A)逐步消隐掉物体,跟多的用于魔法效果

transparent也一样,不过会保留光照信息,更多的用于玻璃效果

image-20210727235906433

metallic可以控制金属效果

smoothness可以控制平滑程度,更加接近镜面

normal map保存了法线信息,用于法线贴图

height map 和法线贴图类似,需要和nomal map结合使用,normalmap只是效果改变了,而heightmap使得几何体实际表面受到影响

当然上述所有效果都是基于standard的shader,unity其实自带了许多比如卡通效果,手绘或者简笔画效果等着色器

image-20210727235916052

3.3 摄像机设置

可以建立多个摄像机(比如很多游戏的小地图其实是个顶视角摄像机),创建方法和创建普通GameObject一样。

默认主相机main camera会被覆盖,是根据depth覆盖的,主相机的depth是最小的-1,后面建立的相机依次是0,1,2…

image-20210727235924781

如果要多个相机并存,只需要缩小其他摄像机的宽高。因为原理是这样的:其实相机覆盖,就是有一个宽度和默认相机一样的视图直接覆盖了底部的…(我觉得unity是不是有点随便??)

image-20210727235931797

3.4 剔除

也就是不渲染相机看不到的地方

image-20210727235942858

点击右下角的bake,unity会对static物体进行剔除(消隐),注意一定要是static的

image-20210727235951019

除了视野外,还有遮挡,试试创建一个巨大的物体挡住相机并重新bake一下,发现也挡住了

image-20210728000003264

当把物体去除后需要重新bake,否则会维持原效果

image-20210728000010022

3.5 后处理效果PostProcess

又叫做全屏幕处理效果,在真正渲染到屏幕之前对后备缓存里的图像处理。可以添加一些比如色调啊之类的。

1.为摄像机添加后处理层

Component->Rendering->Post-Process Layer

如果没有这个组件,先在package manager下载

image-20210728000020419

image-20210728000025140

Layer选择PostProcessing,没有就自己新建自定义一个

2.在场景中添加后处理体

image-20210728000038279

是一个大大的绿色cube,不过多了box collider组件

image-20210728000046090

image-20210728000050851

而Profile就是具体的后处理方法,直接新建,双击定位,打开

image-20210728000058884

双击后如下图添加一个,

image-20210728000106289

image-20210728000112760

3.6 探针

预处理只能用于静态物体,对于非静态物体,如果需要效果优化,可以使用探针。

探针是在场景中提前布点,如果运动物体移动到了探针区域内,就进行渲染效果优化。

3.6.1 光照探针

建立如下场景

image-20210728000122021

为框框中的两个物体添加左右移动动画,接下来添加探针组

image-20210728000129190

image-20210728000133772

这些黄色的点就是探针,场景复杂的时候可以添加的密一点(探针组属性页面可以看到add probe选项,添加探针)

image-20210728000141862

想改变探针布点位置或者添加探针都要先点上图那个允许修改,下面运行,可以看到物体移动时会检测受影响的探针

移动的物体左边有轻微的晕染绿色,右边有轻微的晕染红色效果

image-20210728000154921

3.6.2 反射探针

需要注意的是开启全局光照烘焙,最好一直都开(虽然很吃电脑性能),否则看不到反射效果,将圆变为透明度反射球体

image-20210728000203801

运行,透明球效果比较生硬,不会改变

image-20210728000210634

点击添加多个反射探针

image-20210728000218776

运行,发现移动时反射球会改变了

image-20210728000227951

image-20210728000233408

image-20210728000246321

不管是反射探针还是光照探针,其实就是预处理渲染信息存储在探针中,等到运行的时候将附近各探针的存储的信息进行一个叠加融合作为当前运动到某个位置时的渲染信息,从而提高场景的真实感。

3.7 视频播放

选中墙壁平面,component->video->video player,视频播放的原理是改变了墙壁的材质..

image-20210728000258675

也可以选择URL

image-20210728000306789

当然,以上不涉及到视频的控制,但有时候我们需要对视频进行控制。比如空格暂停

新建c#脚本

image-20210728000313216

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Video;

public class VedioCOntrol : MonoBehaviour
{
VideoPlayer videoPlayer;
// Start is called before the first frame update
void Start()
{
videoPlayer = GetComponent<VideoPlayer>();
Debug.Assert(videoPlayer);
}

// Update is called once per frame
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
if (videoPlayer.isPlaying) videoPlayer.Pause();
else videoPlayer.Play();
}
}
}

3.8 粒子系统

用于廉价而实用的特效,比如爆炸,烟雾,沙尘。会比直接贴多帧图效果好很多

image-20210728000336326

image-20210728000340375

直接就能看到效果,雪花往上飘

我们制作别的效果只需要编辑参数,比如制作火焰

右边很多选项卡的

基本选项卡:start speed 往上升的速度慢点,start color颜色调为火焰色

shape选项卡:angle是往上升的时候的散发度,调为0,radius是出生点那个圈的范围大小,适当调小,使得火焰更集中

随时间的函数选项卡:

image-20210728000347742

可以为其添加子GameObject橙色点光源就可以照亮,也可以将火焰的纹理拖拽给它,是图片的形式,背景要透明,类似布告板,虽然是图片,但是面向摄像机,效果就好像立体的。

当然这样做出来的效果都比较简单,标准资源包有一些预实现,可以拉出来看看,做的很完备,像什么爆炸呀,火焰呀,沙尘暴,烟雾之类的

image-20210728000357069

4. UI

UI不是指3D的物体,而是游戏开始界面的按钮呀,图片呀,文字之类的,当然游戏里面也有。

新建GameObject->UI->Text/Image/Button

为Text新建脚本,内容是一个函数,当函数调用时改变文本

选中button,属性框onclick点击+号,选择Text控件,并选择刚刚写的函数

canvas有个属性叫render mode 可以选择是delay永远显示在屏幕空间,还是camera某个相机的屏幕空间,还是世界空间(虽然是二维的,实际上变成了3D的物体)

image-20210728000411262

5. 物理系统

5.1 物理仿真基础

collider:碰撞检测

regid:刚体(比如重力,或者其他力)

新建如下场景,为墙壁添加材质并画好颜色

image-20210728000425064

给小球添加rigid body

image-20210728000433181

运行,小球会自由落体

image-20210728000440257

在collider里可以看到材质,这个材质不同于普通材质,叫做物理材质,类比生活来说,这个应该叫做“材料”,而普通材质应该对应“涂料”

image-20210728000447041

添加物理材质

image-20210728000453437

拖拽给球体可以自动复制material,双击可以设置摩擦力和弹跳,弹跳为0~1

运行,可以看见皮球效果

image-20210728000503172

为小球添加一个恒力(固定方向的力)

image-20210728000511382

image-20210728000517169

这四个力参数依次是世界空间,局部空间,世界空间力矩,局部空间力矩

可以把小球靠近一点墙,在掉落过程中撞到地面,运行

image-20210728000524328

发现坐标轴会来回转动,这是因为小球落地后会有摩擦和滚动效果(是的添加材质后就自动带有这些功能),导致局部的坐标一直随着滚动变化

可以在rigid body属性中进行约束,阻止滚动

image-20210728000530079

5.2 仿真子弹效果

制作根据蓄力发射子弹的效果,蓄力时间越长射的越远

5.2.1 预制件

将小球直接拖拽到unity资源管理器窗口即可让某个GameObject及其附带的所有属性称为一个可复用的预制件

image-20210728000539211

新建脚本,脚本完成的功能是:利用子弹的恒力组件来施加蓄力效果

利用射线查询功能计算生成的子弹方向

image-20210728000545515

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class fire : MonoBehaviour
{
public ConstantForce bullet; // bullet不能用Transform,而是ConstantForce属性
private float pressedTime = 0f;

// Start is called before the first frame update
void Start()
{

}

// Update is called once per frame
void Update()
{
if (Input.GetButton("Fire1"))
{
pressedTime += Time.deltaTime;
}
if (Input.GetButtonUp("Fire1"))
{
// 从摄像机到鼠标点生成一条射线
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit; //存储射线和xx的交点
if (Physics.Raycast(ray, out hit))
{
// 不能是GetComponent而是Instantiate表示从预制件复制出一个子弹,起始位置为射线的起点(相机),方向为射线方向
ConstantForce cf = Instantiate<ConstantForce>(bullet, ray.origin,
Quaternion.LookRotation(hit.point - ray.origin));
// 添加局部空间恒力
cf.relativeForce = new Vector3(0f, 0f, pressedTime * 10);
}
pressedTime = 0f;
}
}
}

如果子弹偏了是因为预制件复制的子弹默认恒力不是z轴的10

5.3 关节结构

joint可以约束一些物理运动

使用空物体作为约束体

建立两个空物体并分别赋予hinge和spring的joint属性

image-20210728000607459

image-20210728000614203

hinge joint

hinge joint就是类似肘关节这样的关节,绕轴转,或者理解为能挂在空气中的挂钩/钉子

hinge自带rigid body,其中的connected body是要约束的物体

新建一个cube并赋予rigid body属性,放在hinge joint附近位置,拖拽进入connected body

image-20210728000621476

image-20210728000625828

spring joint

空间中的弹簧,能够悬挂物体,操作都类似

悬挂物体后,可以对物体进行子弹打击,尤其是弹簧悬挂的物体,效果很有趣

破坏joint

joint有个break force属性,就是说收到多少力的时候断开约束,我们可以给hinge一个较大的断开力比如1000,spring joint设置为30

image-20210728000633200

运行,就能发现spring joint悬挂的方块被子弹一打击就掉落,而hinge悬挂的方块需要一定的蓄力才能打掉

脚本,破坏事件可以通过脚本捕获到,我们写一个脚本捕获打击的力度

这里直接在unity控制台打印

image-20210728000643677

1
2
3
4
private void OnJointBreak(float breakForce)
{
Debug.Log("this force is: " + breakForce);
}

运行

image-20210728000659707

当然可以试试使用UI空间在屏幕空间右上角记录分数等简单游戏

5.4 碰撞事件

比如我们玩游戏时遇到的反弹或者突然加速效果,本质都是检测到碰撞之后施加特殊的物理效果。新建一个cube并赋予透明材质

image-20210728000707090

cube有个属性叫isTrigger

image-20210728000714861

也就是触发碰撞,如果勾选,那么碰撞时物体不会碰撞而是会穿透物体,适合做一些水面效果。

下面我们模拟一个加速带效果:当穿透这个半透明物体时给子弹进行一个赋力,使子弹加速

首先给子弹添加tag,方便我们控制所有子弹

image-20210728000721527

新建脚本

image-20210728000730938

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void OnCollisionEnter(Collision collision)
{
if (collision.gameObject.tag != "Bullet")
return;
Destroy(collision.gameObject);
}

private void OnTriggerStay(Collider other)
{
if (other.tag != "Bullet")
return;
if (other.attachedRigidbody)
other.attachedRigidbody.AddForce(Vector3.up * 30);
}

运行,碰到半透明方块时就会弹起

6. 人工智能

6.1 自动寻路

先建立如下场景,胶囊体模拟小人

image-20210728000749892

同时为小人添加NavMeshAgent属性

image-20210728000756620

接下来我们编写一个脚本让小人移动到指定位置,需要用到一个AI库中的NavMeshAgent属性

image-20210728000808721

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class Actor : MonoBehaviour
{

public Transform goal; // 目标
private NavMeshAgent agent; //AI库的一个寻路属性类

// Start is called before the first frame update
void Start()
{
agent = GetComponent<NavMeshAgent>();
agent.destination = goal.position;
}

// Update is called once per frame
void Update()
{

}
}

将脚本挂接到胶囊身上,可以看到有个public的参数,也就是目标,我们可以创建一个空GameObject作为目标

现在运行还不会自动寻路,还需要一些预计算:

image-20210728000825526

image-20210728000830261

也就是说,所谓的自动寻路算法需要对现有场景进行烘焙(预计算),那么我们就需要将一些不动的物体设置为static属性(地面,障碍物等),如果障碍物没有被静态烘焙,那么会直接穿过去(也就是没有绕过障碍物进行寻路)

运行,就能自动寻路了,除了避障之外,还会自动寻找最短的路径

image-20210728000843780

修改脚本,实现点击移动到鼠标指定位置

image-20210728000851702

1
2
3
4
5
6
7
8
9
void Update()
{
if (Input.GetMouseButtonDown(0)) {
RaycastHit hit;
if (Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hit, 100)) {
agent.destination = hit.point;
}
}
}

运行点击鼠标左键可以看到效果

台阶跳跃

当我们点击高台的时候发现不能跳上去,只能寻路到离高台最近的位置

方式1:设置set height

属性框有个设置跳跃高度,每次设置完都要bake

image-20210728000908614

当我们看到bake后线没了就说明能跳上去了

image-20210728000914397

方式2:使用off mesh link连接

如果觉得方式1的跳跃不够真实,或者想实现部分游戏里的传送效果(参考马里奥大炮,lol娱乐模式大炮,远程跳跃或者不能直接连接),off mesh link就是这样建立两个物体之间的曲线

下面我们实现直接从地面跳跃到蓝色物体

场景中建立跳跃的起点和终点(cube,终点最好是看得见的..方便演示)

image-20210728000921715

并给其中一个跳跃点添加off mesh link组件

image-20210728000928291

拖入起点和终点

image-20210728000933415

移动的障碍物

对于移动的障碍物,没有办法设置static,需要通过nav mesh obstacle

在场景中新建一面墙,我们都知道如果不配置static的话运动路线穿过墙的话就会直接穿过去。因此我们为墙添加nav mesh obstacle组件

运行,就能避开这个非static的物体了(虽然这个墙并没有为其添加animation,可以自己添加)

image-20210728000941454

说一下carve属性,carve属性能让动态物体也加入烘焙当中

image-20210728000948334

勾选以后我们可以看到bake时这面墙周围也有禁止触碰的区域了

image-20210728000954292

我们都知道bake是提前计算一些复杂的渲染效果,运行时就可以不再计算,那非static的物体勾选这个carve有什么用呢?那就是针对动态生成但生成以后静止的物体

综上:

static:静止物体

非static:移动物体

非static但carve:动态生成但生成以后静止的物体

6.2 敌人巡逻

通常游戏里的敌人都有巡逻逻辑,固定的移动某些空间点,遇到玩家后就会追赶,玩家离开范围后回到自己的巡逻路径

在场景中建立敌人,并建立几个巡逻路径点,左上角的为敌人,右下角的为玩家,用一个父GameObject来管理路径点,路径点位置大致如下

image-20210728001002712

下面通过脚本使其能按规定移动到各个巡逻点

image-20210728001014518

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class Enemy : MonoBehaviour
{
public float patrolSpeed = 3f; // 巡逻移动速度
public float patrolWaitTime = 0.5f; // 到达每个巡逻点停一下
public Transform patrolWayPoints; // 巡逻点都在这个父物体里了,我们直接获取父物体

private NavMeshAgent agent;
private float patrolTimer = 0f; // 记录已经停留的时间,大于patrolWaitTime就移动到下一个巡逻点
private int wayPointIndex = 0; // 巡逻点下标

// Start is called before the first frame update
void Start()
{
agent = GetComponent<NavMeshAgent>();
}

// Update is called once per frame
void Update()
{
Patrolling();
}

private void Patrolling()
{
agent.isStopped = false;
agent.speed = patrolSpeed;
// 判断是否到达某个巡逻点(agent考虑了误差,所以是范围小于该停止的范围)
if (agent.remainingDistance < agent.stoppingDistance)
{
patrolTimer += Time.deltaTime;
if(patrolTimer > patrolWaitTime)
{
if (wayPointIndex == patrolWayPoints.childCount - 1)
wayPointIndex = 0;
else
wayPointIndex++;
patrolTimer = 0f;
}
}
else
{
patrolTimer = 0f;
}
agent.destination = patrolWayPoints.GetChild(wayPointIndex).position;
}
}

关于第34行的那个stopping distance是误差范围判断,其实是nav mesh agent提供的一个误差判断,在脚本挂接到敌人后,还需要调整stopping distance的属性值,最好大于0,以为地形可能有高有矮之类的,存在误差,寻路算法可能没有办法完全达到

image-20210728001033023

6.3 敌人视野

下面实现敌人发现玩家的功能

视野功能的本质是collider

创建一个空物体作为敌人的子物体(视野),添加box collider并调整到合适的视野大小,勾选isTrigger仅作为触发器使用,可以避免物理碰撞

image-20210728001043208

编写脚本,注意脚本是给这个EnemySight的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemySight : MonoBehaviour
{

public float filedOfView = 100f; // 视野角度
public bool playerInsight = false; // 是否发现
public Vector3 playerLastPos; // 玩家最后位置
public Vector3 resetPos = Vector3.back; // 玩家默认位置

private BoxCollider col; // 敌人视野碰撞体
private GameObject player; // 玩家

// Start is called before the first frame update
void Start()
{
col = GetComponent<BoxCollider>();
player = GameObject.FindGameObjectWithTag("Player");
playerLastPos = resetPos;
}

// 碰撞体检测函数
void OnTriggerStay(Collider other)
{
if(other.gameObject == player)
{
playerInsight = false;
// 获得玩家和敌人的连线
Vector3 dir = other.transform.position - transform.position;
// 判断该连线和敌人前方的向量夹角,若大于视野角度的一半(左右两边),就说明玩家可能被看到了
float angle = Vector3.Angle(dir, transform.forward);
if(angle <= filedOfView * 0.5)
{
// 只是可能被看到而已,还需要判断是否有障碍物,可以构造一条射线,raycast是否仅有玩家而没有别的物体
RaycastHit hit;
if(Physics.Raycast(transform.position + transform.up, dir.normalized, out hit, col.size.z))
{
if(hit.collider.gameObject == player)
{
playerInsight = true;
playerLastPos = player.transform.position;
Debug.Log("Find Player");
}
}
}
}
}

private void OnTriggerExit(Collider other)
{
if(other.gameObject == player)
{
playerInsight = false;
}
}

// Update is called once per frame
void Update()
{

}
}

需要注意,collider碰撞检测,当然要求player持有rigid body属性,因此要注意给玩家和敌人添加rigid body属性,除此之外还要勾选isKinematic,表示不受动力学影响(比如被墙壁撞开后受了推力,直接被撞开到地图外)

image-20210728001112392

6.4 攻击与追踪

我们将要修改敌人的脚本逻辑,使其除了巡逻功能外还能拥有追踪和射击动作

整个过程如下:1敌人巡逻,2视野发现玩家,3掉头朝向玩家,4开枪射击,5玩家脱离视野,6敌人追踪,返回2直至追不上,返回1

image-20210728001119408

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class Enemy : MonoBehaviour
{
public float patrolSpeed = 3f; // 巡逻移动速度
public float patrolWaitTime = 0.5f; // 到达每个巡逻点停一下
public Transform patrolWayPoints; // 巡逻点都在这个父物体里了,我们直接获取父物体

private NavMeshAgent agent;
private float patrolTimer = 0f; // 记录已经停留的时间,大于patrolWaitTime就移动到下一个巡逻点
private int wayPointIndex = 0; // 巡逻点下标

public float chaseSpeed = 6f; // 追踪速度
public float chaseWaitTime = 5f; // 允许追踪时间(超过就判定为追不到)
private float chaseTimer = 0f; // 已经追踪的时间
public float sqrPlayerDist = 4f; // 距离平方,平方是为了简化计算
private bool chase = false; // 是否追到

public float shootRotSpeed = 4f; // 发现敌人要先掉头
public float shootFreeTime = 2f; // 子弹冷却时间(换弹),不能无线发射子弹
private float shootTimer = 0f; // 已经冷却的时间

private EnemySight enemySight; // 直接获取视野所用脚本
private Transform player; // 玩家

public Rigidbody bullet; // 子弹

// Start is called before the first frame update
void Start()
{
agent = GetComponent<NavMeshAgent>();
enemySight = transform.Find("EnemySight").GetComponent<EnemySight>();
player = GameObject.FindGameObjectWithTag("Player").transform;
}

// Update is called once per frame
void Update()
{
// 在视野内:射击,没视野了:追踪,追不上了:巡逻
if(enemySight.playerInsight)
{
Shoting(); // 射击
chase = true;
}
else if(chase)
{
Chasing(); // 追踪
}
else
{
Patrolling(); // 巡逻
}
}
}

射击函数

image-20210728001136236

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void Shoting()
{
// 首先掉头朝向玩家
Vector3 lookPos = player.position;
lookPos.y = transform.position.y; // 这里为了简便不进行抬头低头
Vector3 targetDir = lookPos - transform.position;
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(targetDir), Mathf.Min(1f, Time.deltaTime * shootRotSpeed)); // 使用四元数在xx时间内变成指定方向
agent.isStopped = true; // 寻路过程暂时停止

// 发动攻击
if(Vector3.Angle(transform.forward, targetDir) < 2) // 不一定要完全面向玩家,大家都会动,允许一定误差
{
if(shootTimer > shootFreeTime)
{
Instantiate(bullet, transform.position, Quaternion.LookRotation(player.position - transform.position)); // 从预制件中创建子弹,并指定位置和方向
shootTimer = 0f;
}
shootTimer += Time.deltaTime;
}
}

制作子弹和预制件

子弹不要太大,调整缩放

子弹不受物理系统的碰撞影响,勾选isTrigger

为了方便子弹不受重力影响,取消勾选gravity

给子弹一个往前的恒力,局部坐标z为5

image-20210728001154154

为子弹编写脚本

image-20210728001202232

1
2
3
4
5
6
7
8
private void OnTriggerEnter(Collider other)
{
if(other.tag == "Player")
{
GameObject.Destroy(other.gameObject); // 一枪直接能打爆玩家狗头
GameObject.Destroy(gameObject); // 销毁子弹本身
}
}

挂接给子弹,然后创建预制件并删除默认子弹

将子弹预制件作为参数拖拽给Enemy脚本中的bullet参数

image-20210728001223408

现在运行可以看到敌人发射子弹打死玩家了,但是敌人还不会追踪,且射击函数让寻路功能暂停,所以视野捕捉到玩家后就会停下

image-20210728001232971

追踪函数

image-20210728001242525

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private void Chasing()
{
// 寻路
agent.isStopped = false; // 重新打开寻路功能
Vector3 sightDeltaVec = enemySight.playerLastPos - transform.position;
if(sightDeltaVec.sqrMagnitude > sqrPlayerDist) // 计算当前敌人和玩家的距离,其实玩家是上一帧因为不可能知道玩家0.几帧的位置
{
agent.destination = enemySight.playerLastPos;
}
agent.speed = chaseSpeed; // 将巡逻速度换成追踪速度
if (agent.remainingDistance < agent.stoppingDistance)
{
// 走到目标destination的位置时,四处张望一下,如果玩家不见了,就回到巡逻
chaseTimer += Time.deltaTime;
if (chaseTimer > chaseWaitTime)
{
chase = false;
chaseTimer = 0f;
}
}
else
chaseTimer = 0f; // 一直处于追踪状态,不可能因为时间就不追了,一直归0

enemySight.playerInsight = false;
return;
}

运行,可以看到敌人可以巡逻,追踪,射击,重新巡逻

7. 音频

7.1 音频基础

可以直接将音频文件拖拽到场景,会自动建立一个GameObject,附带Audio Source组件,其中的AudioClip属性就是这段音频,而output可以指定一个audio listener,audio listener是音频接收器来模拟人的耳朵,添加音频时默认为main camera添加一个audio listener组件

image-20210728001305357

3D音效:spatial blend拉成3D就可以,运行时镜头的位置决定了声音方向在哪里

mute:暂时静音

Bypass Effects:忽略音效(音效和音量是不一样的)

Play On Awake:自动播放

Loop:循环播放

Priority:优先级0~256

Volume:音量

Pitch:声音播放频率(倍速)

Stereo Pan:声音方向,只对2D声音有影响,3D情况下只跟位置有关

Reverb Zone Mix:决定多少输出信号给回声区域

Doppler Level:多普勒效应

https://baike.baidu.com/item/%E5%A4%9A%E6%99%AE%E5%8B%92%E6%95%88%E5%BA%94/115710?fr=aladdin

Spread:3D音源的发射角度

Volume Rolloff:距离和音量的函数

Max Distance:声音传播最大距离

7.2 混音器

对多个声音进行控制

image-20210728001336769

可以通过不同的组来控制不同的音源(属性中的output对应这里不同的组)

image-20210728001345366

image-20210728001349876

运行,就可以发现多个组对应的音源同时播放了,可以编辑各个组的音频文件的权重,达到混音效果。

父group的调整会影响他的所有子物体

SMB按钮的意思是

S:solo单独播放

M:mute静音

B:bypass取消音效

当我们调整到觉得适合的混音效果时,可以点击Snapshots,保存当前的参数快照

Views:也是可以保存多份,但是保存的是group可见性的自由组合,比如第一份屏蔽了effect,第二份屏蔽了background

Mixer也可以有层次关系:一个混音器控制另一个混音器,比如我们想玩家点空格的时候一种音效,点回车的时候另一种音效

首先拖拽两个音源shoot和explode模拟开枪和跳跃,并管理在父controller下(译为玩家控制音频),创建一个audio mixer对应这个控制音频(这里名字是SoundEffect),两个音频都将output设置为SoundEffect

image-20210728001359598

接下来添加一个组PlayerSound,再将刚才的SoundEffect混音器作为Music混音器的子混音器,并选择组为PlayerSound

image-20210728001406036

当然,交互需要脚本

image-20210728001414870

1
2
3
4
5
6
7
8
9
10
11
12
public class Controller : MonoBehaviour {
public AudioSource shootSnd;
public AudioSource explodeSnd;

// Update is called once per frame
void Update () {
if (Input.GetMouseButtonDown(0))
shootSnd.Play();
if (Input.GetKeyDown(KeyCode.Space))
explodeSnd.Play();
}
}

挂接给controller并将对应的音源GameObject作为参数传递进去,运行,便可以发现既有背景音乐,也有子物体(用户操作时的音源)了

image-20210728001433227

7.3 音效

音效和音频不一样,音频是一段mp3,音效是比如回音,空旷,人声之类的

新建场景,拖入背景音乐音频,并创建一个混音器(这里叫AudioEffectDemo),混音器添加两个group分别作为音乐和音效

image-20210728001440811

将music作为音频的output,运行测试music的参数是否能调整声音大小

下面对effect组点击Add添加音效,unity为我们提供了许多内置音效,这里选择SFX Reverb(回声)

image-20210728001451952

回声需要知道来自什么的回声,所以我们给effects添加一个Receive,给music添加一个send

image-20210728001501813

有了接口后,点击music,在send里选择effects的Receive接口,就可以把音频发送给接收者

image-20210728001509089

注意,音效是有处理顺序的,必须先接收,再回声,因此要调整顺序如下图

image-20210728001520973

运行,还不行,需要点击这里调整回声强度

image-20210728001529682

如果回声效果不明显,可以调整SFX Reverb的参数,比如Room房间大小和Reverb回声强度

image-20210728001536569

除了回声效果,unity也提供了许多其他音效,比如低通滤波器(可以过滤低音保留高音)等等,自己可以尝试,但是要注意顺序,music要先低通再send回音,否则回音接受到的是原音

脚本控制

以上,都是在unity编辑器中设置的,那如果需要动态设置呢?,比如在游戏场景中人物可以调整某个收音机的音量之类,因此可以暴露混音器设置给脚本

image-20210728001546102

因为master可以控制所有子音频,所以我们暴露master的控制脚本,注意是对着Volume点右键而不是对着Attention点右键

完成之后这里会出现一个Exposed Parameters,也就是自定义参数

image-20210728001558353

新建脚本,这里通过键盘上下键来控制音量

image-20210728001607742

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Audio;

public class Adjust : MonoBehaviour {
public AudioMixer audioMixer;

// Update is called once per frame
void Update () {
float v;
audioMixer.GetFloat("MyVolume", out v);
Debug.Log(v);
if (Input.GetKeyDown(KeyCode.UpArrow))
audioMixer.SetFloat("MyVolume", v + 1);
if (Input.GetKeyDown(KeyCode.DownArrow))
audioMixer.SetFloat("MyVolume", v - 1);
}
}

脚本挂接到main canera,mix audio作为参数

image-20210728001625897

运行,通过键盘↑↓调整音量大小

至此音效介绍完毕

8. 网络

2019以后的unity版本现在这里点击一下安装

image-20210728001636004

8.1 双玩家连线

  • 双玩家连线:这种模式下,服务端其实也是其中一个客户端(也是一个unity程序),也就是说,客户端操作一个小人,服务端也有操作小人的功能。但是还是必须先启动服务端。

  • 多玩家连线:这种模式下,服务端通常只做数据的接收、转发和广播,本身并不具有客户端的功能,但是会存储客户端的列表,操作对应的数据是单播还是广播

  1. 添加网络管理器

新建场景,添加一个第三人称预实现小人,添加一个空object,为空物体添加network manager和network manager HUD组件

image-20210728001702120

  1. 为人物添加网络实体和网络广播

为小人复制出预制件

对小人添加 networkIdentity组件,表示该小人是网络实体

image-20210728001711488

勾选下面这个选项,由于在网络游戏中,不宜传输太多数据,比如人物可以在自己本地可以控制的,一个客户端只有一个玩家。只需要向网络广播自己的一些信息即可,因此就可以勾选下面这个选项。

image-20210728001722884

那勾选了本地操作之后,怎么向网络广播自己的位置信息呢?

为小人添加network transform组件

image-20210728001729928

就可以向网络中发送自己的transform了

完成之后,点击apply all可以应用到对应的预制件

image-20210728002138028

现在运行并不是真正的网络数据,还需要修改人物脚本,进入这个脚本

image-20210728002152705

打开可以看到MonoBehaviour,这是单人游戏的意思

image-20210728002200786

需要改成如下:网络游戏

image-20210728002207350

这个类就可以调用一些联网功能

我们对其修改,目的是:人物动作只在本地,但是一些位置等信息需要向网络广播

image-20210728002215358

保存,apply all后就可以删除场景中的小人了,咱们只保留预制件,小人交由服务端生成

\3. 服务端生成预制件

刚刚说了小人应该在服务端生成而不是一开始就在客户端中

image-20210728002223807

\4. 运行

现在一切已经就绪,那么我们怎么模拟联机呢,当然可以找两台同一wifi/局域网下的电脑,也可以本机模拟,我们直接build一下作为一个,然后编辑器中运行作为另一个(当然你也可以build后运行两个)。总之无论如何,打开两个就行了

需要注意的是,必须先启动服务端,再启动客户端连接它

image-20210728002232572

我们可以看到成功联机了,但是只有位置联机了,动作没有改变(A端自己的小人会在B端移动,但不会在B端做动作)

image-20210728002240525

unity为网络游戏优化的规则就是默认不传输所有数据,至于位置信息是因为我们添加了network transform组件才进行传输

怎么样才能让动作也作为网络数据传输呢?动作其实就是动画animation,到这里就明白了:network animation,是的

image-20210728002247837

踩坑:预制件的修改

但是要修改预制件的东西,最好先将预制件拖入场景中变为实体,改动完再apply all,再删除场景中的GameObject

因为参数通常是传入GameObject,如下

image-20210728002257617

如果只有Player预制件没有生成一个Player的GameObject实体,那么这里会发现没有Player可选

将上述所有修改完成再apply all,删除场景中的实体即可

重新build,联机,发现动作也有了,延迟取决于网络!!!!

8.2 联网子弹

准备工作:单机子弹和发射

下面制作子弹,先创建一个球体,属性如下

image-20210728002306968

并作为预制件,将场景中的子弹删除

编辑player脚本,使其可以发射子弹

image-20210728002316200

image-20210728002320281

1
2
3
4
void Fire()
{
GameObject bullet = Instantiate(bulletPrefab, transform.position + transform.up + transform.forward * 2, transform.rotation);
}

这时候已经完成了单机发射子弹了,可以运行测试

下面继续制作联网子弹

将子弹预制件拖拽到场景编辑,添加network identity和network transform

由于我们不希望子弹的位置自动传输,因此将send rate设置为0

apply

将子弹预制件纳入到网络管理器的生成列表种,使得子弹由网络生成

image-20210728002341869

image-20210728002357707

需要注意的是,由网络生成的列表必须带有[Command],而有Command的函数必须是以Cmd开头

新建血量脚本

我们想做一个功能是当血量为0时,重新生成一个玩家,我们知道现在Player是在服务端生成的,但是生成后是交给客户端来对这个Player执行

unity提供了一个由服务端调用,客户端执行的函数

image-20210728002413241

为子弹建立脚本,碰撞检测和调用血条减少(总的来说就是造成伤害)

image-20210728002422534

1
2
3
4
5
private void OnTriggerEnter(Collider other)
{
// 通过SendMessage方法尝试调用gameobject名为TakeDamage的脚本函数,如果该gameobject没有这个函数则DontRequireReceiver不执行
other.gameObject.SendMessage("takeDamage", 60, SendMessageOptions.DontRequireReceiver);
}

这里说明一下,为什么没有用if(other.gameobject == “player”),因为这个碰撞检测可能对player有效,对enemey也有效,后期可能对其他的物体也有效,那么这样设计是比较麻烦的,unity提供的这种消息机制就可以让所有的物体都接收,然后尝试调用TakeDamage方法,如果物品没有这个方法就不作为,如果物品有这个方法就调用。

将脚本拖拽给bullet预制件,将health脚本拖拽给player,运行

8.3 NPC

NPC就是共同的敌人,(或者在某些游戏中不是敌人,总之共同的就对了)

  1. 重新制作预制件

拖拽一个小人到场景中,重命名为NPC,并修改材质使其看起来有区别于enemy和player

image-20210728002455249

重新制作预制件并命名为NPC预制件,并将场景中的小人删除

  1. 为NPC添加网络管理器

新建空物体,添加network identity组件,勾选server only(NPC应该设置为仅由服务端生成,不受客户端控制)

image-20210728002505705

  1. 动态随机生成多个NPC脚本

image-20210728002519323

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class NPCSpawner : NetworkBehaviour
{
public GameObject NPCPrefab;
public int numOfNPC;

// 服务端启动时的一个生命周期虚函数
public override void OnStartServer()
{
for(int i = 0; i < numOfNPC; i++)
{
// 随机位置和朝向
Vector3 spawnPos = new Vector3(Random.Range(-5f, 5f), 0f, Random.Range(-5f, 5f));
Quaternion spawnRot = Quaternion.Euler(0, Random.Range(0, 180), 0f); // 更习惯用欧拉角
GameObject NPC = Instantiate(NPCPrefab, spawnPos, spawnRot);
NetworkServer.Spawn(NPC);
}
base.OnStartServer();
}
}

拖拽给生成器gameobject

image-20210728002547216

填入参数

image-20210728002553809

  1. 注册到网络生成器列表

image-20210728002615085

  1. 为NPC添加血条效果

现在运行可以正常生成NPC但不会收到伤害,因为Health是客户端的脚本功能

image-20210728002624636

修改如下

image-20210728002634445

image-20210728002639459

运行,可以联网打怪

image-20210728002649385

9. 时间轴

9.2 创建time line

先来看看如何创建一个时间轴创建一个timeline

9.1 和动画的异同

timeline是引擎后来提供的一个新功能。相比于animation,timeline可以认为是控制场景的整体动画,既可以对单个物体也可以控制多个物体协同运动,同时可以播放声音,粒子效果,摄像机跟踪等等。而animation只可以控制单个物体,且该物体的多个动画片段需要animation controller协调进行叠加或者混合,多个物体的动画需要各自的animation。可以理解成timeline是横向的,一个object控制多个物体同时播放动画,animation是纵向的,每个物体能在自己的时刻播放自己的动画。

动画轨道

新建一个空物体,添加Playable Director组件,另一种方式是window->timeline->create(这种方式就类似于animation)

如下图操作,拖拽两个物体进入animation轨道,由Director这个timeline物体的timeline组件控制

image-20210728002718180

在各自的轨道上创建自己的animation,可以像animation一样录制,也可以如下直接植入预制动画片段

image-20210728002724881

image-20210728002729117

需要注意的是:录制的话,对动画中需要变动的transform属性进行右键add key

录制完后,可以对某一段动画进行封装成一个完整的片段

image-20210728002736908

除了动画轨道,还可以有音频轨道和控制轨道

音频轨道audio track

和动画类似,创建音源,创建轨道,添加声音片段,可以融合

image-20210728002746608

控制轨道 control track

可以动态生成一个物体

image-20210728002755453

image-20210728002801717

自定义轨道Playeable track

可以接收自定义脚本,脚本类需要集成PlayableAsset

下面实现一个UI空间Text显示当前轨道的播放状态

image-20210728002812452

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.UI;

public class MyplayableAsset : PlayableAsset {
public ExposedReference<Text> myText;
public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
{
var playable = ScriptPlayable<MyplayableBehaviour>.Create(graph);
playable.GetBehaviour().status = myText.Resolve(graph.GetResolver());
return playable;
}
}
public class MyplayableBehaviour:PlayableBehaviour
{
public Text status;
public override void OnBehaviourPlay(Playable playable, FrameData info)
{
if(status != null)
{
status.text = "Playing";
}
}
public override void OnBehaviourPause(Playable playable, FrameData info)
{
if(status != null)
{
status.text = "End playing";
}
}
}

简单来说,timeline绑定了Text类型的控件,能够允许这个控件在某段时间生效,至于代码可能是固定写法,日后再探究

创建UI空间Text并拖入,运行

image-20210728002830934

9.3 角色动画

拖拽一个第三人称小人预实现,取消脚本和animator controller,运行,此时小人相当于静态模型,添加timeline,动画依次为idle->walk->run,运行,此时发现有两个问题

1.角色不能掉头,即使调整场景中的小人rotY为180,但是运行的时候还是会背对相机,这是因为角色的transform已经交给目前出于激活状态的timeline控制,需要在这个地方调整

image-20210728002839165

2.动画被重置,尤其是walk->run,walk已经走了一段距离,但是run的时候会自动先回到原点,而不是接着walk的位置跑。右击run片段选中如下选项

image-20210728002850515

3.动画过渡太僵硬,回顾之前animation的融合,timeline也提供了非常方便的过渡功能。只需要将两个动画片段交叉即可自动过渡。

image-20210728002857991

细节:由于动画片段的独立性,由于速度不同或者其他问题,导致自动融合中会出现比如walk->run时出现滑步的现象,自己对位置做一些微调即可

image-20210728002906829

可以像animation一样进行动画的叠加,比如在动画1的时候添加转身,遮罩(身体的某个部位做不一样的动画)

image-20210728002914631

如果动作部位冲突又没有遮罩的话,后面的轨道会覆盖前面的轨道

9.4 脚本控制

通常timeline都是达到某个场景触发,下面我们模拟脚本控制timeline的触发,新建空物体并为其创建脚本如下

image-20210728002922653

关于第8行,其实写public也行,只是提一下有这么一种写法,既是private又可以在unity编辑器中暴露参数,像button一样传入参数

image-20210728002933022

运行,依次按下QE

9.5 Cinemachine

转场动画中经常见到的多摄像机动画。比如在游戏开始前,相机先展示敌人和一些特殊道具的位置,随后镜头才给到主角。其实就是Cinemachine在多个摄像机之间的转换。

timeline对多摄像机有很专业的动画调控支持

第一次使用需要先安装一下

image-20210728002947208

  1. 新建cinemachine track

添加后发现多了一个track,新建它

image-20210728002958774

cinemachine其实是采用了虚拟摄像机来模拟多摄像机效果

  1. 为主摄像机添加cinemachint brain组件

image-20210728003010383

\3. 新建虚拟摄像机

如下,可以创建一个简单的虚拟摄像机,unity也为我们提供了许多具备一定功能的摄像机,这里我们选择一个freelook:这个虚拟摄像机允许我们角度自由的观察一些物体。

再建立一个带轨道的推拉摄像机:这个虚拟摄像机可以对一个物体前后推拉达到远近变化观察的效果

image-20210728003024160

image-20210728003028592

  1. 调整虚拟摄像机达到预期效果

咱们目的是让推拉带轨道摄像机沿着设定的轨道移动,并时刻注释红色物体,自由摄像机注释蓝色物体。并在timeline中设置为先观察蓝物体,再观察红物体的效果

image-20210728003036607

推拉摄像机的路径设置如下

image-20210728003042730

推拉摄像机参数设置如下:其中Path offset设置成0表示摄像机严格按轨道移动

image-20210728003055081

设置完成后,推拉摄像机就可以在轨道上找到最近的点去观察红色物体了

另外,这里为了方便看到效果,要让红蓝物体在动画开始时自动运动(之前设置了要按下QE)

image-20210728003110890

  1. 调高main camera的优先级

自己可以对比观察一下,是这样的,camerachine所谓的虚拟摄像机并不记录画面,而是将虚拟摄像机看到的画面传输给camera(类似于无人机,无人机在飞,拍到的画面是发送到操控者的屏幕上,无人机自己并没有保存照片的功能)。

如果不调高main camera的优先级,那么会显示虚拟摄像机开始的位置观察的画面,因为虚拟摄像机只有第一帧的画面,后面的画面不记录不更新,而是发送给了main camera,因此我们看到的就是虚拟摄像机似乎没动。所以我们应该一直看main camera,要将main camera的优先级调高,覆盖其他摄像机。

image-20210728003120944

  1. timeline控制两个camerachine

image-20210728003128455

运行观看效果

10. 2D游戏

unity新建工程的时候,如果想要创建二维工程,需要在创建时点击2D

image-20210728003146829

长这样

image-20210728003154116

可以看见工具栏有个2D视图,其实只要换成3D就变成3D工程了,所谓2D游戏不过是把z轴去掉

10.1 精灵

2D游戏中的纹理默认是精灵类型,2D游戏就是由一个个的图片(或者说纹理或者说精灵)+移动+脚本制作的

将一张图片拖拽到unity,可以看到默认是精灵类型

image-20210728003202760

如果图片本身就具有alpha透明通道,那么图片可能会不显示(透明嘛),可以如上图去除透明通道,记得点击apply

image-20210728003212754

拖拽两个物体进入场景,发现被覆盖,可以手动调整sorting layer,下面的order in layer表示即使是同一层也有遮挡顺序

image-20210728003221835

点击三角形,点击add layer,并添加自定义层,下面的优先级高于上面

image-20210728003229743

编辑好后,将场景中的物体应用到各自的层,坦克就不会被背景遮住了

image-20210728003245946

当我们拖拽子弹后,发现是一连串的

image-20210728003253713

但我们知道这其实是多帧连续播放的动图,unity给我们提供了一个非常简便的切割方式

image-20210728003301196

其中slice提供了很多切割模式

image-20210728003308281

automatic是自动切割,采用了最小包围盒算法

size是指定长和宽的像素

count是指定几行几列

完成后记得点击apply

此时再拖拽到场景,会自动提示创建animation

image-20210728003321609

创建完动画后运行即可看到效果

多个物体批量调整大小

image-20210728003331060

创建发射子弹脚本

image-20210728003339325

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(Rigidbody2D))]
public class Bullet : MonoBehaviour
{
[SerializeField] private float speed = 2.0f;
private Rigidbody2D body;

// Start is called before the first frame update
void Start()
{
body = GetComponent<Rigidbody2D>();
}

// Update is called once per frame
void Update()
{
body.MovePosition(transform.position - transform.right * speed * Time.deltaTime);
}

private void OnCollisionEnter2D(Collision2D collision)
{
if(collision.gameObject .tag == "Player")
{
GameObject.Destroy(collision.gameObject);
GameObject.Destroy(gameObject);
}
}
}

挂接到子弹,子弹会自动添加rigid body组件,还需要手动添加一个box collider组件用于碰撞检测,同时还需要把重力取消

image-20210728003359001

接下来完成坦克生成子弹,因此需要制作预制件,并reset位置(到时候作为坦克的子物体)

image-20210728003407652

新建发射脚本

image-20210728003414971

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Spawner : MonoBehaviour
{

[SerializeField] private float shootTimer = 3f; // 冷却时间
float timer = 0f; // 已经冷却时间
[SerializeField] GameObject bullet; // 实例化的子弹


// Update is called once per frame
void Update()
{
if(timer > shootTimer)
{
Instantiate(bullet, transform); // 从transform位置生成子弹
timer = 0f;
}
timer += Time.deltaTime;
}
}

挂接给Spawner发射器,并将子弹预制件参数拖拽进去,运行

颜色:

可以调整color属性

光照:

2D游戏默认没有光照,因为材质默认不会受到光照效果,可以手动添加光源,并添加材质,选择漫反射

image-20210728003433219

将材质拖拽给2D物体,就可以看到光照效果,如果没有,注意光源是否离得太远(调整z值)和角度(这个要注意,比如点光源默认rotX是90,建议切换成3D视图来调整)

image-20210728003448422

10.2 瓦片地图

tilemap,是为了2D游戏经常需要的一种格子排列布局

image-20210728003457334

但是这样很费时间,于是有了瓦片地图功能

  1. 新建瓦片地图tilemap

image-20210728003504817

除了tilemap,下面还有六边形等

image-20210728003514418

记得将这个tilemap图层覆盖在background之上

image-20210728003527618

  1. 调出瓦片调色板tile palette

image-20210728003537970

image-20210728003543552

  1. 拖拽到调色板

image-20210728003556032

用笔刷刷出连续的效果

image-20210728003603506

点击edit可以调整调色板上的瓦片效果,可以用多个物体组装成一个瓦片,鼠标框选后作为一个整体再使用笔刷

10.2.1 碰撞体

image-20210728003615993

可以为tilemap中的每一片瓦片里的每一个方块都添加碰撞体

这时候我们可能需要进行一些碰撞体的融合

image-20210728003623887

添加这个会自动添加rigid body,如果是地形的话,不受重力影响,最好直接设置成静态

image-20210728003631293

tilemap允许使用碰撞组合

image-20210728003644698

10.3 角色控制

下面我们学习2D下的游戏角色控制

  1. 先添加资源包,只需要添加这几张图片就行

image-20210728003700545

如果打开sprite editer就可以发现都切割好了

  1. 拖拽idle动画(一定要记得是拖拽到场景而不能是Hierarchy),起名Player作为角色,修改为actor层,设置tag为Player,添加capsule collider 2D并调整大小到包裹小人范围,添加rigid body2D组件,不希望旋转所以锁定旋转轴

image-20210728003740511

  1. 动画修改

不多说了,编辑ide的状态机,添加walk run jump 并创建三个参数,添加转换条件

speed:速度,Ground:是否触碰地面,vSpeed:纵向跳跃速度

idel->walk:速度大于0.01 反之亦然

walk->run:速度大于0.1,反之亦然

image-20210728003753861

最后状态机如下图

image-20210728003800252

添加对应的Motion(如果没有的话就先将几个png拉入场景,因为这个不是从资源包弄出来的动画,而是从png里面通过sprite editor切割出来的)

  1. 创建player脚本

这个脚本比较复杂,但目的只有一个,就是通过按键改变animator动画中这几个参数,剩下的不用理会,动画都做好了。

image-20210728003813871

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Character : MonoBehaviour
{

[SerializeField] float maxSpeed = 10f;
[SerializeField] float jumpForce = 400f;
[SerializeField] LayerMask whatIsGround; // 哪些层需要地面检测

Transform groundCheck; // 我们会在小人脚的位置建立一个空子物体用于碰撞检测
float groundRadius = 0.5f; // 距离地面多少判断为着地
bool grounded = false;
Animator anim;
Rigidbody2D body;
bool facingRight; // 面朝右边
bool jump = true; // 是否在跳跃

private void Awake()
{
// 这里没有用start而是用awake
groundCheck = transform.Find("GroundCheck");
anim = GetComponent<Animator>();
body = GetComponent<Rigidbody2D>();
}

private void FixedUpdate()
{
grounded = false;
// OverlapCircleAll的作用是从某个点开始,在多大范围内(二维平面的圈),在某个层是否有相交物体
Collider2D[] colliders = Physics2D.OverlapCircleAll(groundCheck.position, groundRadius, whatIsGround);
foreach (Collider2D collider in colliders)
{
if (collider.gameObject != gameObject) // 养成习惯,碰撞体不是自己,其实这里还要检测是否是敌人或者别的什么
grounded = true; // 总之这里就先简单的认为碰撞了且不是自己就说明是地面
}

anim.SetBool("Ground", grounded);
anim.SetFloat("vSpeed", body.velocity.y); // 这个可以获取速度

float h = Input.GetAxis("Horizontal"); // 这个可以获取用户是按下←(-1)还是→(1)
if (!jump) // 这样做可以禁止连跳
{
jump = Input.GetButtonDown("Jump"); // 这个可以获取用户是否按下↑
}
Move(h, jump); // 移动函数,在下面给出实现
jump = false;
}

/**
* 移动函数,判断move是往左还是往右,有没有发生跳跃
*/
void Move(float move, bool jumping)
{
if (grounded) // 很细节,着地了才判断速度,否则在空中速度够了就跑起来了,打断了跳跃动作
{
anim.SetFloat("Speed", Mathf.Abs(move));
body.velocity = new Vector2(move * maxSpeed, body.velocity.y); // 新速度
// 改变朝向
if (move > 0 && !facingRight)
{
Flip(); // 掉头方法
}
else if (move < 0 && facingRight)
{
Flip();
}

// 跳跃判断
if (jumping && anim.GetBool("Ground"))
{
grounded = false;
anim.SetBool("Ground", false);
body.AddForce(new Vector2(0f, jumpForce));
}
}
}

/**
* 掉头方法
*/
void Flip()
{
facingRight = !facingRight;
Vector3 scale = transform.localScale;
scale.x *= -1;
transform.localScale = scale;
}
}

脚本挂接给小人

这里为了方便我们先选Everything,如果游戏做的细的话可以是地面Terrain和一些被认为地面的一些层

image-20210728003842929

添加脚本中提到的用于碰撞检测的子物体

image-20210728003849611

运行发现有延迟,应该把转换调成立即转换

image-20210728003856744

相机跟随

这个比较简单的一种做法是让main camera作为Player的子物体

当然这里由于子弹碰撞后销毁Player及其子物体导致撞到子弹后报如下错误,这里先不管他(解决方法是要么碰撞时判断不销毁相机,要么摄像机的移动不采用这种方式,而是脚本移动相机的transform的position)

image-20210728003904121

单方向碰撞检测

通常这种2D游戏都是可以从下面穿上去的,unity为我们提供了这个功能

选中两个tilemap并添加如下组件

image-20210728003919285

勾选如下两个选项

image-20210728003927487

运行

11. 天空盒

image-20210728003939740

12. 忽略碰撞检测

部分物体是不需要碰撞检测的,unity提供了非常方便的选项供我们忽略而不需要在脚本中判断。

首先我们添加一个tag专门标识忽略碰撞检测的物体比如IgnoreCollider

如下图

image-20210728003953715

矩阵少一半,说明不支持单向碰撞检测(A可以碰撞B,B不可以碰撞A),因此如果有其他复杂的需要仍然通过脚本实现,只推荐勾选图片中的一个,除非确定某物体完全不受碰撞

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×