Unity3D开发学习日志(上篇)

前言

  这个博客好像已经很久没有发过技术性文章了╮(╯▽╰)╭

  正如上篇博文所言,笔者自本周起将进入多个项目的开发迭代阶段(如果真觉得我上篇文章只是篇拗口的酸诗的话就当我没说吧23333)。而在某个项目中,笔者需要借助Unity3D编写一个演示程序。这就是这篇博文的起源。我试图将我探索的思路、过程以及踩到的坑如实记录下来,藉以自鉴,也希望能帮到其他初学者。

  还有一点要题一下,本篇文章名为“Unity3D开发学习日志”,其含义为“对使用Unity进行开发的过程进行学习的日志”。即本篇文章将以特定的开发目标为线索,为读者构建出最简单的认知模型,随后给出必要的知识点,而绝非对Unity3D事无巨细的全面介绍。如果有需要百科全书式的教程的话,还请读者回头去读Unity3D的官方文档吧。

  废话不多说,让我们开始动手吧。

安装

  Unity3D的安装十分简单,没什么值得说的。注意如果需要交叉编译,需要在安装时勾选目标平台构建工具包。还有就是如果安装完成之后发生了无限加载打不开项目的情况,请关闭杀毒软件以及Windows自带的防火墙

工作区概览

  快速解除对一个陌生的软件的恐惧感的最佳方法是熟悉其工作区的组成。

  让我们来看看下面这张对Unity3D默认工作区的不同组成进行了标注的图片,然后一个个认识它们:

  

  ①层级视图:层级视图(Hierarchy)**描述了游戏场景中的所有游戏对象(GameObject)及其层级关系。游戏对象可以为模型、Unity3D内建实体,光源以及相机等等。游戏对象也可以是一个没有内容只有名字的空对象(Empty Object)**。游戏对象之间可以存在着类似单继承的层级关系。所有子对象的位置、姿态以及尺寸等属性均随着父对象的改变而一同改变。空对象常用于组合多个游戏对象。

  ②场景视图:**场景视图(Scene View)**在编辑模式下是一个交互式沙盒,你可以在此快速游览游戏的场景,以及选择、放置并移动游戏对象。场景视图里的物体通常与特定游戏对象绑定在一起。同时,点击相机时,右下角会显示出当前相机的视场预览。右上角的指示器能提醒你此刻镜头的俯仰角,点击中间的立方体能够快速切换透视模式,而点击其各个面的对应椎体则可快速将镜头于该平面对准。

  ③工程面板与控制台:工程面板可以直观的展示项目所使用的所有资源,包括场景、脚本、模型、纹理等等。工程面板内的文件与实际项目文件夹内的文件一一对应。你可以直接在工程面板里创建脚本等素材,或者把外部素材拖拽到面板内以获得该素材的一份副本。控制台页面则常用于输出编译与调试的信息,你也可以通过C#代码向控制台发送消息。

  ④属性面板:**属性面板(Inspector)**又被称为监视面板,选择一个游戏对象后面板就会显示其相关的数据,包括位姿、属性、脚本、成员等等内容。在属性面板上对游戏对象的修改会立刻应用到游戏对象上。

  ⑤工具栏与菜单:位于整个工作区顶部的工具栏主要由其左侧的场景视图工具与其中央的试玩控制组成。点击中央的播放按钮即可编译脚本并在场景视图中进行试玩。再次点击即可结束试玩,回到编辑模式。菜单则包含了文件、编辑等常用功能的栏目。

场景构建

  我们的目标是在游戏里创建一个立方体的测试房间。具体操作十分简单,但需要注意的是,默认情况下,平面只有单面材质。所以在做天花板时,我们需要把平面旋转180度。其它的面亦然。

脚本系统

脚本概述

  Unity3D使用C#作为其脚本的编程语言。简单的说,脚本即指控制游戏对象的行为与表现的代码。Unity3D的脚本系统使用了Mono项目,使得C#代码(.NET框架)能够被编译成跨各个不同平台的机器码。

脚本的生存周期

  脚本的生命周期见下图:

  

  这张图片对脚本的执行流程的描述已经非常清楚了。在编写脚本时,我们的主要工作是实现Unity3D提供的接口函数(如OnGUI等函数),而具体的调用过程则由Unity3D的脚本引擎负责实现。

我的第一个脚本

  在工程面板的左侧选中Assets文件夹,右键-Create-C# Script,此时你的第一个脚本文件已创建完毕并显示在了工程面板的右侧。其图标为一个大大的C#。随后你还需要将其拖拽至层级视图中的MainCamera上。完成这些操作后,你的脚本将会在游戏开始时被加载。如果要编辑脚本文件,双击其图标即可。

图形化用户界面

  在本节,笔者将具体描述如何实现两个基本的**图形化用户界面(Graphical User Interface, 简称GUI)**控件:调试输出框与按钮。

实现原理

  首先,在Unity3D里实现GUI的途经通常有两种:直接在场景里画出一个平面然后让相机正对着它,或是使用代码在相机渲染完成的屏幕上绘制控件。至于这两者的差别,首先是更新的渲染阶段不同:前者的更新是在场景渲染阶段,而后者的更新是在专门的GUI渲染阶段。其所导致的结果是,前者在生成立体、动态GUI的能力上要强于后者,而在编写简单控件时使用后者则更加方便。

  为了简化难度,笔者选择使用后一种方式实现GUI。

绘制一个调试输出框

  我们要做的首先是一个调试输出框。由于Unity3D自带的调试输出的位置是控制台面板,这就使得游戏测试时阅读调试信息不是很方便。于是,我们希望在游戏画面的左上角添加一个能立刻显示调试信息的区域。

  首先考虑用于储存调试信息的数据类型:显然最符合直觉的数据结构是一个定长的消息队列,新的消息从队列末尾入队,被显示过的消息从队列头出队。于是我们首先在脚本文件的末尾追加一个包含了链表的新类DebugInfoManager,其对应的完整C#代码如下:

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
public class DebugInfoManager
{
LinkedList<string> infos;
public void Init()//初始化
{
infos = new LinkedList<string>();
this.Info("Debugging mode has been Enabled. ");
}

public void Info(string info)//将消息加入队列
{
infos.AddLast(info);
while (infos.Count > 7) infos.RemoveFirst();
}

public void Render()//绘制调试输出框
{
string str = "";
foreach (string info in infos) {
str += info + '/n';
}//获得当前调试输出的文字内容
GUIStyle style = GUI.skin.box; style.fontSize = 20;//放大字体
style.alignment = TextAnchor.UpperLeft;//使字符左上对齐
GUI.Box(new Rect(10, 10, 400, 170), str, style);//绘制
}
}

  随后修改主类的Start方法,并添加OnGUI方法。完成后,你的主类看起来应该像这样:

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

public class UserInterface : MonoBehaviour {
DebugInfoManager debugger = new DebugInfoManager();

void Start ()
{
debugger.Init();//初始化调试输出器
}

void Update ()
{
}

void OnGUI()
{
debugger.Render();//转移控制权
}
}

  以下是试玩时的效果图:

  

绘制两个按钮,处理点击事件

  接下来我们要在屏幕的右下角绘制两个按钮,并编写其相应的处理程序。为了简化难度,我们仅让按钮在被点击时,输出一段文字。由于本段内容比较简单,这里直接给出修改以后OnGUI方法的代码。代码内对相关内容均已给出注释。

1
2
3
4
5
6
7
8
9
10
11
12
void OnGUI()
{
debugger.Render();
//更改样式(增大字体,居中对齐)
GUIStyle style = GUI.skin.box; style.fontSize = 20; style.alignment = TextAnchor.MiddleCenter;
//绘制控件
if (GUI.Button(new Rect(Screen.width - 200, Screen.height - 60, 80, 40), "Left", style))
//设置响应处理
debugger.Info("You press the LEFT button!!!");
if (GUI.Button(new Rect(Screen.width - 100, Screen.height - 60, 80, 40), "Right", style))
debugger.Info("You press the RIGHT button!!!");
}

  效果图:

  

相机转动

  接下来我们的目标是在按下右下角的那两个按钮之后,让摄像头向左或向右旋转一个直角。在开始编程之前,先来补充一点数学知识。

欧拉角、万向锁与四元数

  首先引入用于描述姿态的两种常用的数据结构:欧拉角(Euler angles)**与四元数(Quaternion)**。

  让我们先从相对直观的欧拉角入手。下面给出了一张来自Wikipedia的欧拉角旋转演示。

  

  可以看到,物体围绕着自己的三个自然坐标轴分别进行了旋转。而我们构造欧拉角时所需要的三个参数,正是这三个自然坐标轴的旋转角度。欧拉角的实质是一个三维向量,我们并不需要关注其具体实现细节。

  在Unity3D中构造欧拉角也非常的简单,只需要使用如下构造函数:

1
  Vector3 eularAngle = new Vector3(x, y, z);

  其中的数值 x, y, z 分别为物体在各个自然坐标轴上旋转的角度。

  虽然欧拉角能相当直观的描述物体的旋转,但是单单使用欧拉角描述旋转时会遇到一个不可避免的问题:**万向锁(Gimble lock)**。

  注意到使用欧拉角描述旋转过程时,如果将Y轴旋转90度,会使得Z轴与X轴重合,进而导致旋转表示系统失去一个维度。其结果如下图所示:

  

  此时,无论旋转X轴还是旋转Z轴,物体本身的姿态变化都是相同的。因此,使用欧拉角描述物体的姿态变化具有缺陷。此时,就需要引入另一个数学工具,四元数。

  四元数是一种超复数,也即对复数的扩展,由实数加上三个不同维度的虚数组成。对四元数及其描述变换的原理的理解需要引入旋转群等高等代数的概念,限于篇幅,本教程不作过多陈述,感兴趣的读者可以自己去查阅相关定理以及证明过程。在本篇教程中,我们只需要知道,Unity3D(以及其它几乎所有需要描述物体三维姿态的程序)的内部使用四元数描述物体的姿态。

  在Unity3D中,可以很容易的使用如下代码从欧拉角生成四元数。

1
Quaternion quat = Quaternion.Euler(new Vector3(x, y, z));

左转与右转

  在脚本与游戏对象绑定后,可在脚本主类内使用transform.Rotate方法使游戏对象旋转。该方法的参数可以是欧拉角或四元数。

  现在让我们来修改一下那两个按钮的响应处理代码,使主相机能够进行旋转:

1
2
3
4
5
6
7
8
9
10
11
//(在OnGUI内)
if (GUI.Button(new Rect(Screen.width - 200, Screen.height - 60, 80, 40), "Left", style))
{
transform.Rotate(new Vector3(0, -90, 0));
debugger.Info("You press the LEFT button!!!");
}
if (GUI.Button(new Rect(Screen.width - 100, Screen.height - 60, 80, 40), "Right", style))
{
transform.Rotate(new Vector3(0, 90, 0));
debugger.Info("You press the RIGHT button!!!");
}

  接着,让我们试玩一下:

可以由墙上的标注文字与左上角的调试输出容易的推断出旋转的代码运行成功。

平滑化处理

  虽然我们实现了主相机的固定角度转动,可是此时的效果只是“画面快速一闪,旋转随即完成”。而作为优秀的美工师,我们一定不会愿意接受这种呈现。于是在这里,我们将使用**球面线性插值(Spherical linear interpolation, 常简称 Slerp )**技术对表示旋转的四元数进行平滑插值,进而实现镜头平滑转动的效果。

  下面给出实现了该效果的主类代码,重要的地方均已加上注解。

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
public class UserInterface : MonoBehaviour {
DebugInfoManager debugger = new DebugInfoManager();
Quaternion targetRotation;//目标姿态
bool rotating = false;//是否处于旋转过程,用于防止用户在旋转的过程中点击转向而改变目标姿态、


void Start () {
debugger.Init();
}

void Update () {
//根据球面线性插值的结果改变位姿。Slerp的第三个参数影响了插值的密度也即转动的速度。
if (rotating) transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 3);
//这里的IF有两个作用:1. 提前结束最后缓慢的镜头移动 2. 结束旋转过程
if (Quaternion.Angle(targetRotation, transform.rotation) < 0.5f)

{
transform.rotation = targetRotation;
rotating = false;
}
}

void OnGUI()
{
debugger.Render();
GUIStyle style = GUI.skin.box; style.fontSize = 20; style.alignment = TextAnchor.MiddleCenter;
if (GUI.Button(new Rect(Screen.width - 200, Screen.height - 60, 80, 40), "Left", style))
{
debugger.Info("You press the LEFT button!!!");
if (!rotating)
{
//从当前的姿态计算出目标姿态
targetRotation = Quaternion.Euler(transform.eulerAngles + new Vector3(0, -90, 0));
//开始旋转
rotating = true;
}
}
if (GUI.Button(new Rect(Screen.width - 100, Screen.height - 60, 80, 40), "Right", style))
{
debugger.Info("You press the RIGHT button!!!");
if (!rotating)
{
targetRotation = Quaternion.Euler(transform.eulerAngles + new Vector3(0, 90, 0));
rotating = true;
}
}
}
}

一个坑点

  在完成了镜头转动的代码之后,笔者本来想让镜头两边的墙向外移动,让视野看上去变得更加广阔一些。随后却发现无论如何调整房间的大小,相机预览都没有任何改变。后来一拍脑袋发现,让相机始终位于场景中央的话,由于其视场角约为100度,而正好始终大于正方形内其正对的面的两个侧楞与正方体中心的夹角(一个直角)。现在考虑的解决方案是让相机绕着房间中心做圆形轨迹的运动的同时转动其视角。等实现以后回头再来更新这一段内容。

结语

  至此,本篇Unity3D初窥之旅即将划上句号。本教程参阅了包括Unity3D官方文档、Wikipedia等多篇在线内容,而教程中的代码、Unity3D界面截图等均为笔者原创。在此,笔者要感谢所有不辞辛劳地将自己的经验总结后发布在互联网上的博主们。笔者也相信并期待着与他们一同成为促进互联网时代知识传播的重要力量。笔者也衷心希望每位读者都能够在读完本文后有所收获。如果发现文章内容有误,或是存在难以理解的内容,欢迎发送邮件或在下方留言。

  从下周开始,笔者将尝试着在Unity3D内动态加载模型文件,并试着实现一些简单的前后端逻辑。届时如果发现了有趣的经历,笔者也会适当挑选一部分内容编著成另一篇开发学习日志。

  最后给出本篇所编写的脚本文件的最终代码:

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
using System.Collections.Generic;
using UnityEngine;

public class UserInterface : MonoBehaviour {
DebugInfoManager debugger = new DebugInfoManager();
Quaternion targetRotation;
bool rotating = false;

void Start () {
debugger.Init();
}

void Update () {
if (rotating) transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 3);
if (Quaternion.Angle(targetRotation, transform.rotation) < 0.5f)

{
transform.rotation = targetRotation;
rotating = false;
}
}

void OnGUI()
{
debugger.Render();
GUIStyle style = GUI.skin.box; style.fontSize = 20; style.alignment = TextAnchor.MiddleCenter;
if (GUI.Button(new Rect(Screen.width - 200, Screen.height - 60, 80, 40), "Left", style))
{
debugger.Info("You press the LEFT button!!!");
if (!rotating)
{
targetRotation = Quaternion.Euler(transform.eulerAngles + new Vector3(0, -90, 0));
rotating = true;
}
}
if (GUI.Button(new Rect(Screen.width - 100, Screen.height - 60, 80, 40), "Right", style))
{
debugger.Info("You press the RIGHT button!!!");
if (!rotating)
{
targetRotation = Quaternion.Euler(transform.eulerAngles + new Vector3(0, 90, 0));
rotating = true;
}
}
}
}
public class DebugInfoManager
{
LinkedList<string> infos = new LinkedList<string>();
public void Init()
{
infos = new LinkedList<string>();
this.Info("Debugging mode has been Enabled. ");
}

public void Info(string info)
{
infos.AddLast(info);
while (infos.Count > 7) infos.RemoveFirst();
}

public void Render()
{
string str = "";
foreach (string info in infos)
{
str += info + '/n';
}
GUIStyle style = GUI.skin.box; style.fontSize = 20;
style.alignment = TextAnchor.UpperLeft;
GUI.Box(new Rect(10, 10, 400, 170), str, style);
}
}