Jiahonzheng's Blog

离散仿真引擎基础

字数统计: 3.9k阅读时长: 15 min
2019/09/07 Share

游戏就是模拟世界或构建虚拟世界。用计算机技术呈现现实或虚拟世界的动态场景的系统,统称“离散仿真系统”。

课程讲义地址:3D 游戏编程与设计

游戏引擎

游戏引擎是一组游戏运行部件以及软件工具的集合。随着技术进步,多数现代游戏引擎都包含以下部件,其架构如下图所示。

由上图可知,游戏引擎分为两个层次:

  • 游戏内容层:管理游戏需要的数据
  • 游戏引擎层:支撑游戏的运行与人机交互

Answers

下面是 离散仿真引擎基础 章节的作答。

Question 1.1

解释 游戏对象(GameObjects) 和 资源(Assets)的区别与联系。

游戏对象,即 GameObject ,是 Unity 场景中的所有实例的基类,是 Component 的容器,游戏中的所有对象都是游戏对象。

资源是游戏素材,可被多个游戏对象使用,甚至可实例化为游戏对象(例如预设),通常包括 ScriptsPrefabsScencesAudio 等。

区别和联系:游戏对象是资源的具体表现,可通过生成预设的方式变为资源。资源可被游戏对象使用,其中的预设可实例化为游戏对象。

Question 1.2

下载几个游戏案例,分别总结资源、对象组织的结构(指资源的目录组织结构与游戏对象树的层次结构)。

下载并打开 Platformer 游戏项目,Assets 组织情况如下。

由此可见,资源 Assets 以文件夹目录的形式来组织结构,按照文件类型分类:音频、动画、场景、人物、预设、使用说明等。

GameObjects 的组织情况如下。

从上图可知,游戏对象 GameObjects 以层次结构来组织,可分为摄像机、布景元素、事件系统等层次结构。

Question 1.3

编写一个代码,使用 debug 语句来验证 MonoBehaviour 基本行为或事件触发的条件。

  • 基本行为:Awake、 Start、 Update、FixedUpdate、LateUpdate。
  • 常用事件:OnGUI、OnDisable、OnEnable。

我们添加一个 Cube 对象,并为其添加 CubeBehaviourScript 脚本。

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

public class CubeBehaviourScript : MonoBehaviour
{
// Awake is called when the script intance is being loaded.
void Awake()
{
Debug.Log("Awake");
}

// Start is called before the first frame update.
void Start()
{
Debug.Log("Start");
}

// FixedUpdate is called every fixed framerate frame, if the MonoBehaviour is enabled.
void FixedUpdate()
{
Debug.Log("FixedUpdate");
}

// Update is called once per frame.
void Update()
{
Debug.Log("Update");
}

// FixedUpdate is called every frame, if the MonoBehaviour is enabled.
void LateUpdate()
{
Debug.Log("LateUpdate");
}

// OnGUI is called for rendering and handling GUI events.
void OnGUI()
{
Debug.Log("OnGUI");
}

// OnEnable is called when the object becomes enabled and active.
void OnEnable()
{
Debug.Log("OnEnable");
}

// OnDisable is called when the behaviour becomes disabled or inactive.
void OnDisable()
{
Debug.Log("OnDisable");
}
}

点击运行按钮,即可见到以下输出。

点击 Inspector 中的 Cube 属性最上方的 checkbox ,使之为取消勾选的状态,即可观察到以下输出。

下面是对 Unity 常用事件的执行时机的描述。

事件名称 执行条件或时机
Awake 当一个脚本实例被载入时Awake被调用。或者脚本构造时调用
Start 第一次进入游戏循环时调用
FixUpdate 每个游戏循环,由物理引擎调用
Update 所有 Start 调用完后,被游戏循环调用
LastUpdate 所有 Update 调用完后,被游戏循环调用
OnGUI 游戏循环在渲染过程中,场景渲染之后调用
OnEnable 当游戏对象被启用时调用
OnDisable 当游戏对象被禁用时调用

Question 1.4

查找脚本手册,了解 GameObject,Transform,Component 对象。

  • 分别翻译官方对三个对象的描述(Description)。
  • 描述图中 table 对象(实体)的属性、table 的 Transform 的属性、 table 的部件。
    • 本题目要求是把可视化图形编程界面与 Unity API 对应起来,当你在 Inspector 面板上每一个内容,应该知道对应 API。
    • 例如:table 的对象是 GameObject,第一个选择框是 activeSelf 属性。
  • 用 UML 图描述三者的关系(请使用 UMLet 14.1.1 stand-alone版本出图)

GameObject

Base class for all entities in Unity Scenes.

游戏对象是 Unity 场景中的所有实例的基类。

Transform

Position, rotation and scale of an object. Every object in a Scene has a Transform. It’s used to store and manipulate the position, rotation and scale of the object.

Transform 用来定义游戏对象的位置、旋转、以及缩放比例。

Component

Base class for everything attached to GameObjects.

Component 是所有附加于 GameObjects 上内容的基类。

描述 table 的各种属性

table 的对象(实体)是 GameObject 。第一个选择框是 activeSelf 属性,用于是否启用该对象,可触发 OnEnableOnDisable 函数;位于第一个选择框右边的是 name 属性;第二个选择框是 static 属性,用于指定游戏对象是否是静态的;位于第二行的分别是 TagLayer 属性,分别指对象的所属标签所在的层;位于第三行的是 Prefab 属性,用于设置该对象的预设。

tableTransform 属性主要由三个属性构成:PositionRotationScale 。当前(图中)所显示的 Transform 属性表明该对象的位置是 (0, 0, 0) ,旋转角度为 (0, 0, 0) ,长宽高为 (1, 1, 1) 。

tableComponent 部件有 TransformCube (Mesh Filter)Box ColliderMesh RendererDefault-Materialchair1chair2chair3chair4

UML

下面是 GameObjectComponentTransformUML 关系图。

Question 1.5

整理相关学习资料,编写简单代码验证以下技术的实现:

  • 查找对象
  • 添加子对象
  • 遍历对象树
  • 清除所有子对象

查找对象

我们可通过以下 GameObject 的静态方法查找指定游戏对象。

1
2
3
4
5
6
7
8
9
10
// 根据名称查找单个游戏对象
public static Find(string name);
// 根据标签查找单个游戏对象
public static FindWithTag(string tag);
// 根据标签查找多个游戏对象
public static FindGameObjectsWithTag(string tag);
// 根据类型查找单个游戏对象
public static Object FindObjectOfType(Type type);
// 根据类型查找多个游戏对象
public static Object FindObjectsOfType(Type type);

我们创建一个 Empty 的游戏对象,并为其添加 EmptyBehaviourScript 。同时,我们添加 Table 预设,其中 Table 部件的标签为 TableChair1Chair2Chair3Chair4 的标签为 Chair

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

public class EmptyBehaviourScript : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
{
// 按名称查找单个游戏对象
GameObject table = GameObject.Find("Table");
Debug.Log(table);
}
{
// 按标签查找单个游戏对象
GameObject table = GameObject.FindWithTag("Table");
Debug.Log(table);
}
{
// 按标签查找多个游戏对象
GameObject[] chairs = GameObject.FindGameObjectsWithTag("Chair");
Debug.Log(chairs);
}
}
}

按下运行按钮后,可得以下日志输出。

如上图所示,我们已成功通过各种方式查找到指定的游戏对象。

添加子对象

为添加子对象,我们需要通过上一部分谈及到的 查找对象 的方法获取 父对象 ,随后调用 GameObjectCreatePrimitive 静态方法,创建并设置子对象,最终设定子对象的 transform.parent 为父对象的 transform

修改 EmptyBehaviourScriptStart 方法为以下内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
void Start()
{
// 获取父对象
GameObject table = GameObject.Find("Table");
// 创建子对象
GameObject chair5 = GameObject.CreatePrimitive(PrimitiveType.Cube);
// 设置子对象名称
chair5.name = "Chair 5";
// 设置子对象的 Transform 属性
chair5.transform.position = new Vector3(0, 2, 0);
// 在父对象添加子对象
chair5.transform.parent = table.transform;
}

按下运行按钮后,结果如下。

如上图所示,我们已成功为 Table 添加名为 Chair5Cube 部件。

遍历对象树

我们可以通过遍历指定对象的 transform 属性,获取其对象树。

1
foreach (Transform child in parent.transform);

修改 EmptyBehaviourScriptStart 方法为以下内容。

1
2
3
4
5
6
7
8
9
10
void Start()
{
// 获取指定对象
GameObject table = GameObject.Find("Table");
// 遍历指定对象的 transform 属性
foreach (Transform child in table.transform)
{
Debug.Log(child);
}
}

按下运行按钮后,可得以下日志输出。

如上图所示,我们成功输出了 Table 的对象树,其对象树由 4 个部件构成。

清除所有子对象

我们在遍历对象树过程中,对每个子对象执行 Destroy 方法即可实现对所有子对象的清除。

1
2
3
4
5
// 遍历指定对象的 transform 属性,对子对象执行 Destroy 方法
foreach (Transform child in parent.transform)
{
Destroy(child.gameObject);
}

修改 EmptyBehaviourScriptStart 方法为以下内容。

1
2
3
4
5
6
7
8
9
10
void Start()
{
// 获取指定对象
GameObject table = GameObject.Find("Table");
// 遍历指定对象的 transform 属性,对子对象执行 Destroy 方法
foreach (Transform child in table.transform)
{
Destroy(child.gameObject);
}
}

按下运行按钮后,结果如下。

如上图所示,我们成功清除 Table 的所有子对象。

Question 1.6

资源预设(Prefabs)与 对象克隆 (clone)。

  • 预设(Prefabs)有什么好处?
  • 预设与对象克隆 (clone or copy or Instantiate of Unity Object) 关系?
  • 制作 table 预制,写一段代码将 table 预制资源实例化成游戏对象。

预设的优点

  • 相同的游戏对象可以用同个预设来创建,提高游戏资源的复用率。
  • 对预设进行修改后,其所创建的所有游戏对象都会发生改变。

预设与对象克隆

预设创建的实体会根据预设的变化而变化,而克隆的实体不会因为原实体的变化而变化。

实例化 Table 预设

我们在 SampleScene 新建名为 TableCube 作为桌子,并在其下新建四个 Cube 作为椅子,最终将其制作为 Prefab 预制件。

我们移除 Table 对象,并新建一个空对象,命名为 MainController ,并为其添加如下脚本。

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;

public class MainControllerBehaviourScript : MonoBehaviour
{
// 装载预制件,通过 Inspector 拖拽初始化。
public GameObject table;

// Start is called before the first frame update.
void Start()
{
// 根据预制件初始化对应实例。
GameObject instance = Instantiate(table);
// 设置预制件实例名称。
instance.name = "Prefab Table Instance 1";
// 设置预制件实例位置。
instance.transform.position = new Vector3(0, Random.Range(0, 3), 0);
instance.transform.parent = this.transform;
}

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

通过将 Assets 中的 Table 预制件拖拽至 Inspector 中的 Table 属性框,点击运行按钮,即可在 Game 栏目观察到我们已将根据预制件初始化一实例,并将其显示。

Question 2

编程实践,小游戏。

  • 游戏内容:井字棋 或 贷款计算器 或 简单计算器 等等。
  • 技术限制:仅允许使用 IMGUI 构建 UI 。
  • 作业目的:
    • 了解 OnGUI() 事件,提升 debug 能力。
    • 提升阅读 API 文档能力。

项目地址:Tic Tac Toe

在线预览:demo.jiahonzheng.cn/Tic-Tac-Toe/

预览视频:Unity 井字棋游戏演示

游戏实现了单人模式双人模式,使用 IMGUI 构建视图。

主菜单页面如下。

游戏页面如下。

Player 1 胜利页面如下。

Player 2 胜利页面如下。

平局页面如下。

Question 3.1

微软 XNA 引擎的 Game 对象屏蔽了游戏循环的细节,并使用一组虚方法让继承者完成它们,我们称这种设计为“模板方法模式”。

  • 为什么是“模板方法”模式而不是“策略模式”呢?

模板方法的主要思想:定义一个算法流程,将一些特定步骤的具体实现、延迟到子类,使得可以在不改变算法流程的情况下,通过不同的子类、来实现“定制”流程中的特定的步骤。

策略模式的主要思想:使不同的算法可以被相互替换,而不影响客户端的使用。

与策略模式相比,模板方法更加强调算法流程是按照特定顺序被执行的,开发者对于流程上的可变点的访问是受限的,通常用受保护的虚函数来定义可变点。

微软 XNA 引擎,已经定义好了游戏循环,即算法流程的执行顺序,为了避免开发者修改算法流程顺序,所以采用“模版方法”,而不是“策略模式”。

Question 3.2

将游戏对象组成树型结构,每个节点都是游戏对象(或数)。

  • 尝试解释组合模式。
  • 使用 BroadcastMessage() 方法,向子对象发送消息。你能写出 BroadcastMessage() 的伪代码吗?

组合模式

在组合模式中,我们把一组相似的对象当作一个单一的对象。组合模式依据树形结构来组合对象,用来表示部分以及整体层次,创建了对象组的树形结构。

BroadcastMessage

我们添加 Table 预设至 SampleSceneTable 预设由 5 个部件组成:父部件是 Table ,其下有四个子部件。

我们为 Table 添加 TableBehaviourScript 脚本,脚本具体代码如下。

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

public class TableBehaviourScript : MonoBehaviour
{
// Update is called once per frame
void Update()
{
// 当任意键被按下时,调用 BroadcastMessage 方法,给子对象发送消息
if (Input.anyKeyDown)
{
this.BroadcastMessage("BroadcastReceive", "Hello");
}
}
}

同时,我们也为 Chair1Chair2Chair3Chair4 添加 ChairBehaviourScript ,脚本具体代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ChairBehaviourScript : MonoBehaviour
{
// 响应父对象的 BroadcastMessage
private void BroadcastReceive(string message)
{
Debug.Log(message);
}
}

按下运行按钮后,点击页面后,即可观察以下的日志输出。

如上图所示,我们成功实现了父对象发送消息给子对象。

使用反射实现 BroadcastMessage

通过反射技术,我们可以实现自己的 BroadcastMessage 函数,修改 TableBehaviourScript 代码如下:

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

public class TableBehaviourScript : MonoBehaviour
{
// Update is called once per frame
void Update()
{
// 当任意键被按下时,调用 BroadcastMessage 方法,给子对象发送消息
if (Input.anyKeyDown)
{
OwnBroadcastMessage("BroadcastReceive", "Hello");
}
}

// 反射实现 BroadcastMessage
void OwnBroadcastMessage(string methodName, object parameter)
{
// 遍历对象树
foreach (Transform child in this.transform)
{
// 获取 Script 部件实例(可能存在多个)
var monos = child.gameObject.GetComponents<MonoBehaviour>();
foreach (var mono in monos)
{
var type = mono.GetType();
// 设定方法绑定属性
var bindingAttr = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public;
// 获取指定名称的方法
var method = type.GetMethod(methodName, bindingAttr);
// 判断方法是否存在
if (method != null)
{
// 若存在方法,传递参数并调用
method.Invoke(mono, new object[] { parameter });
}
}
}
}
}

按下运行按钮后,点击页面后,即可观察以下的日志输出。

如上图所示,我们成功实现了父对象发送消息给子对象,说明 OwnBroadcastMessage 实现正确。

Question 3.3

一个游戏对象用许多部件描述不同方面的特征。我们设计坦克(Tank)游戏对象不是继承于GameObject对象,而是 GameObject 添加一组行为部件(Component)。

  • 这是什么设计模式?
  • 为什么不用继承设计特殊的游戏对象?

我们设计坦克(Tank)游戏对象不是继承于GameObject对象,而是 GameObject 添加一组行为部件(Component),这属于装饰器模式,使用了组合的思想。

继承是实现类复用的重要手段,却不是唯一的手段,我们通过类的关联组合同样可实现类的复用。继承模式相比于组合模式,有以下缺点:

  • 继承不利于测试。在进行单元测试时,我们需要 mock 数据。在使用继承的情况下,我们不得不 mock 基类。如果使用组合,则简单很多,而且我们可通过注入不同的实例来方便的完成 mock 和线上实例的切换。
  • 继承不利于封装。在继承中,如果子类依赖父类的行为,子类将变得脆弱。因为一旦父类行为发生变化,子类也将受到影响。
  • 继承的灵活性不如组合
CATALOG
  1. 1. 游戏引擎
  2. 2. Answers
    1. 2.1. Question 1.1
    2. 2.2. Question 1.2
    3. 2.3. Question 1.3
    4. 2.4. Question 1.4
      1. 2.4.1. GameObject
      2. 2.4.2. Transform
      3. 2.4.3. Component
      4. 2.4.4. 描述 table 的各种属性
      5. 2.4.5. UML
    5. 2.5. Question 1.5
      1. 2.5.1. 查找对象
      2. 2.5.2. 添加子对象
      3. 2.5.3. 遍历对象树
      4. 2.5.4. 清除所有子对象
    6. 2.6. Question 1.6
      1. 2.6.1. 预设的优点
      2. 2.6.2. 预设与对象克隆
      3. 2.6.3. 实例化 Table 预设
    7. 2.7. Question 2
    8. 2.8. Question 3.1
    9. 2.9. Question 3.2
      1. 2.9.1. 组合模式
      2. 2.9.2. BroadcastMessage
      3. 2.9.3. 使用反射实现 BroadcastMessage
    10. 2.10. Question 3.3