Unity场景切换

引言

在游戏开发中,场景切换,这一看似简单的操作,实则蕴含着提升游戏体验、增强故事叙述能力的无限潜力。今天,就让我们一同踏上这场Unity中场景切换的魔法之旅,揭开那些让游戏世界流畅衔接、引人入胜的秘密。

在快节奏的游戏开发中,场景切换往往被视为一个“必经之路”,而非核心功能。然而,正是这些看似不起眼的环节,构成了玩家游戏体验的重要组成部分。一个精心设计的场景切换不仅能有效减少加载时间带来的等待感,还能通过视觉和情感的引导,加深玩家对游戏世界的沉浸感。更重要的是,它还能作为游戏叙事的一部分,引导玩家逐步揭开故事的真相,体验更加丰富和深刻的游戏内容。想象一下,你的玩家正在紧张刺激的战斗后,通过一段华丽的过渡动画,瞬间穿越到宁静祥和的村庄;或者是在解开谜题后,随着一阵悠扬的音乐,眼前的景象逐渐模糊,再清晰时已是另一个充满挑战的关卡。这样的场景切换,不仅让玩家感受到游戏的连贯性和惊喜,更激发了他们对未知世界的好奇心和探索欲。而这,正是我们今天要探讨的——如何在Unity中利用场景切换的魔法,为你的游戏增添无限魅力。

撰写一篇关于在Unity中进行场景切换的博客文章是一个很好的主意,因为这对于开发任何类型的游戏或应用来说都是一项基础且重要的技能。以下是一个结构化的博客文章大纲,以及一些关键内容的建议,帮助你撰写这篇博客。

什么是场景(Scene)?

在Unity中,**场景(Scene)**是构建游戏世界的基石。所有GameObject都在场景中,它包含了游戏运行时所需的所有元素,如3D模型、灯光、摄像机、UI界面、音效以及脚本逻辑等。每个场景都代表了一个独立的游戏环境或关卡,玩家在其中进行互动和探索。Unity编辑器提供了直观的工具来创建、编辑和管理多个场景。开发者可以轻松地添加、删除或修改场景中的元素,并通过场景管理器来组织和管理这些场景文件。游戏通常由多个场景组成,这些场景通过场景切换相互连接,共同构成了完整的游戏流程。场景切换不仅是技术上的实现,更是游戏叙事和玩家体验的重要组成部分。

如何切换场景

SceneManager类

SceneManager是Unity提供的一个用于管理场景加载、卸载和切换的类。它提供了丰富的API来支持场景的各种操作。

1.首先打开菜单栏的File-> Build Settings,确保你的场景已经添加到这里,记住对应场景的序号。

2.SceneManager类提供了SceneManager.LoadScene方法实现场景的加载与切换,以下是代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using UnityEngine;  
using UnityEngine.SceneManagement; // 确保使用了UnityEngine.SceneManagement命名空间

public class SceneSwitcher : MonoBehaviour
{
// 场景切换函数,该函数可以通过按钮或在任何需要的情况下被调用,实现场景转换
public void SceneChange()
{
// 使用SceneManager的LoadScene方法来加载新场景
// 第一个参数是场景名称
// 第二个参数是加载模式
SceneManager.LoadScene("nextScene", LoadSceneMode.Single);
// SceneManager.LoadScene(1); // 第一个参数可直接使用场景名称,也可使用对应的场景序号
// 无论使用的是场景名称还是序号,场景都必须要被添加在Build中
// 第二个参数一般情况下省略
}
}

3.函数被调用时,游戏会暂停当前场景的所有操作,等待新场景完全加载到内存中并激活后,再继续执行后续操作。这个过程是阻塞的,即直到新场景加载完成,游戏才会继续运行。这个过程,也被称为同步加载。

同步加载

过程:

  1. 触发加载:通过调用场景加载的方法,并传入要加载的场景名称或索引作为参数,来触发场景的加载过程。
  2. 等待加载:在加载过程中,游戏会暂停当前场景的所有操作(如渲染、物理计算、用户输入响应等),直到新场景完全加载到内存中。
  3. 场景切换:当新场景加载完成后,Unity会销毁当前场景(如果加载模式为Single),并激活新场景,然后游戏继续执行后续操作。

可如果我们的目标场景数据十分庞大呢?这意味着我们在等待加载的过程中就会出现我们都遇到过的事情,那就是卡顿。我们需要等很长时间来加载目标场景的数据。这时如果我们使用同步加载,在这期间游戏处于暂停中,玩家很可能以为自己卡了,大大降低体验度。使用异步加载就可以解决这个问题。

异步加载

异步加载中加载新场景的行为是在后台线程中进行的,不影响主线程(即游戏当前场景)的运行。它的过程为触发加载 -> 后台加载 -> 场景切换

使用了异步加载,我们就可以在等待加载的过程中播放一些其他操作,如待机画面、加载进度条等。

以下是使用异步加载进行场景切换并添加加载进度条与点击继续的操作示例:

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
using UnityEngine;  
using UnityEngine.SceneManagement;
using UnityEngine.UI; // 引用UI相关命名空间
using TMPro;

public class SceneLoader : MonoBehaviour
{
public GameObject loadScreen; // 加载界面Panel
public Slider slider; // 加载界面进度条
public TextMeshProUGUI text; // 进度文本

public void LoadScene()
{
StartCoroutine(Loadlevel()); // 使用协程
}

// 异步加载场景,控制进度条
IEnumerator Loadlevel()
{
loadScreen.SetActive(true); // 显示加载界面

AsyncOperation operation = SceneManager.LoadSceneAsync("MainScene");
// SceneManager.LoadSceneAsync方法返回一个AsyncOperation对象,
// 在加载过程中,你可以通过返回的AsyncOperation对象来获取加载进度、检查是否加载完成等。

operation.allowSceneActivation = false; // 默认加载完成不切换场景

while (!operation.isDone)
{
slider.value = operation.progress; // 让进度条的值等于加载进度(slider与加载进度的值都是0~1的浮点数)

text.text = (int)(operation.progress * 100) + "%"; // 显示加载进度百分数

if (operation.progress >= 0.9f)
{
slider.value = 1; // 场景加载完成后,progress返回的值不是1,所以要单独设定进度条的值
text.text = "点击任意位置继续"; // 加载完成后百分比进度变为提示
if (Input.GetMouseButtonDown(0))
{
operation.allowSceneActivation = true; // 切换场景
}
}

yield return null;
}
}
}

除了很大型的游戏项目之外,大部分情况下,场景的加载总是一瞬间完成的,如果仍然想要做出像大型游戏加载那样的加载界面效果,可以尝试使用异步加载制作伪加载进度条,即进度条的进度与播放时间由开发者设定,实际上与加载时间无关,实现过渡界面的效果。

区别

同步加载异步加载
定义加载新场景时,游戏会暂停当前场景的所有操作,等待新场景完全加载并激活后继续执行。加载新场景的行为是在后台线程中进行的,不影响主线程(即游戏当前场景)的运行。
过程1. 触发加载
2. 等待加载(游戏暂停)
3. 场景切换(加载完成后继续)
1. 触发加载
2. 后台加载(游戏继续运行)
3. 场景切换(加载完成后通过回调或检查状态继续)

对游戏流畅度的影响

  • 同步加载:由于加载过程中游戏会暂停,因此当加载的场景较大或资源较多时,会导致明显的卡顿或延迟,影响用户体验。
  • 异步加载:加载过程在后台进行,不影响游戏当前场景的运行,因此即使加载大型场景也不会造成游戏卡顿,提升了游戏的流畅度和用户体验。

使用场景

  • 同步加载:适用于场景较小、资源较少,或对加载时间要求不高的场合。
  • 异步加载:更适用于场景较大、资源较多,或对加载时间有较高要求的场合,如制作场景切换过程中的过渡界面、实现无缝加载等。

实现方式

  • 同步加载:直接调用加载场景的方法,并等待加载完成。
  • 异步加载:调用加载场景的方法时,需要处理返回的异步对象(如AsyncOperation),以便在加载过程中进行进度监控、加载完成后的回调等。

同步加载和异步加载在Unity中各有其适用场景和优缺点。在实际开发中,应根据游戏的具体需求和资源情况来选择合适的加载方式。

场景间的数据传递

  • 全局变量:使用静态类或单例模式存储全局数据,以便在不同场景之间共享信息。
  • PlayerPrefs:适用于存储少量非敏感数据,如玩家设置。 Unity日记:存档系统-PlayerPrefs
  • SceneManager的DontDestroyOnLoad方法:用于在场景切换时保留某些对象,如游戏管理器或UI系统。

关于并发编程

在Unity中,我们刚刚探讨了场景切换的两种方式:同步加载与异步加载。特别是异步加载,通过SceneManager.LoadSceneAsync方法,我们能够在不阻塞主线程的情况下加载新场景,这种非阻塞的特性极大地提升了游戏的流畅度和用户体验。而实现这种异步加载背后,Unity的协程(Coroutine)机制扮演了至关重要的角色。协程允许我们在Unity的主线程上**“伪并行”**地执行代码块,每个协程在每次迭代后都会暂停,直到下一次主线程循环中再次被唤醒。这种机制虽然并非传统意义上的并行处理,但它有效地利用了Unity的单一更新循环,模拟了并发执行的效果。

那么,当我们谈论到并发编程时,我们实际上是在探讨如何在多个处理单元(如CPU核心)上同时执行多个任务,以充分利用硬件资源,提高程序的整体执行效率。在Unity这样的游戏开发环境中,并发编程不仅限于简单的协程使用,它还包括多线程编程、任务并行库(TPL)的使用(在Unity中不直接支持,但可通过C#的System.ThreadingSystem.Threading.Tasks命名空间实现跨平台兼容性处理)、以及针对特定硬件优化的并行计算API(如Compute Shaders、Job System和Burst Compiler等Unity 2018及以后版本中引入的高级功能)。

从异步加载和协程的视角过渡到并发编程,我们可以理解为:**协程为我们提供了一种在游戏主循环内管理复杂流程和异步操作的有效方式,而并发编程则是将这种思想扩展到了更广泛的层面,即跨越多个处理单元来并行执行多个任务。**在Unity中,合理利用并发编程技术,可以显著减少游戏运行时的延迟,提升游戏性能,特别是在处理大量数据计算、物理模拟、AI决策等计算密集型任务时。

然而,并发编程也伴随着复杂性增加和潜在的资源竞争、死锁等问题。因此,在设计并发系统时,开发者需要仔细规划任务之间的依赖关系,合理使用同步机制,确保数据的一致性和系统的稳定性。在Unity的上下文中,这意味着要深入理解Unity的生命周期、线程安全以及如何利用Unity提供的并发工具和最佳实践来优化游戏性能。

概念

并发编程是一种程序设计方法,‌它由若干个可同时执行的程序模块组成,‌这些模块被称为进程。‌这些进程可以同时在多台处理器上并行执行,‌也可以在一台处理器上交替执行。‌采用并发程序设计可以使外围设备和处理器并行工作,‌从而缩短程序执行时间,‌提高计算机系统效率。‌

在并发编程中,‌有几个核心概念需要理解:‌

  • 进程与线程:‌进程是执行过程中分配和管理资源的基本单位,‌而线程是进程的一个执行单元,‌是进程内可调度的实体。‌线程也可以被称为轻量级进程,‌它是比进程更小的独立运行的基本单位。‌多个线程可以存在于一个进程中,‌并发执行并共享资源(‌如内存)‌,‌而不同的进程不共享这些资源。‌
  • 同步和异步:‌同步和异步通常用来形容一次方法调用。‌同步方法调用需要调用者等待方法调用返回后才能继续后续的行为,‌而异步方法调用一旦开始,‌方法调用就会立即返回,‌允许调用者继续执行其他操作,‌而异步方法在另一个线程中“真实”地执行。
  • 并发和并行:‌并发和并行都可以用来表示两个或多个任务一起执行,‌但侧重点不同。‌并发强调任务可以交替执行,‌而并行则强调真正的同时执行。‌在单核CPU下,‌通过任务调度器将CPU的时间片分配给不同的程序使用,‌由于CPU在时间片之间快速切换,‌人类感觉是同时运行的,‌这被称为并发。‌而在多核CPU下,‌多个核心可以同时处理多个线程,‌这才是真正的并行执行。
  • 临界区:‌临界区通常指共享数据,‌可以被多个线程使用。‌当有线程进入临界区时,‌其他线程或进程必须等待。‌例如,‌当一个线程正在修改共享变量时,‌其他试图访问该变量的线程必须等待直到第一个线程完成操作。‌

进程、线程与协程的关系

进程、线程与协程的关系

线程和协程都是进程的子集,一个进程可以有多个协程,一线程也可以有多个协程,进程基于程序主体。

IO密集型一般使用多线程或多进程CPU密集型一般使用多进程强调非阻塞异步并发的一般都用协程

进程

进程是系统分配资源和调度资源的一个独立单位,每个进程都有自己的独立内存空间不同进程间可以进行进程间通信。进程重量级比较大,占据独立内存,上下文进程间的切换开销(栈寄存器、虚拟内存、文件句柄)比较大,但相对稳定安全。进程的上级为操作系统,有自己固定的堆栈。

进程间通信(IPC)

  • 管道(Pipe):管道是Unix中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。其本质是内核中固定大小的缓冲区。
  • 命名管道(Named Pipes):“命名管道”又名“命名管线”(Named Pipes),命名管道支持可靠的、单向或双向的数据通信。不同于匿名管道的是:命名管道可以在不相关的进程之间和不同计算机之间使用,服务器建立命名管道时给它指定一个名字,任何进程都可以通过该名字打开管道的另一端,根据给定的权限和服务器进程通信。
  • 消息队列(MQ,Message Quene):消息队列用于在进程间通信的过程中将消息按照队列存储起来,常见的MQ有ActiveMQ、RocketMQ、RabbitMQ、Kafka等。
  • 信号量(Semaphore):有时被称为信号灯,是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之前,线程必须获取一个信号量。
  • 共享内存(Share Memory):共享内存是三个IPC机制中的一个。它允许两个不相关的进程访问同一个逻辑内存。共享内存是在两个正在进行的进程之间传递数据的一种非常有效的方式。
  • 套接字(Socket):就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。

对于游戏开发者来说,最为常用的无疑是Socket,这是长连接网络游戏的核心。

线程

线程也被称为轻量级进程,是操作系统调度(CPU调度)执行的最小单位,是进程的子集。

线程本身基本不拥有资源,而是访问隶属于进程的资源,一个进程拥有至少一个或多个线程,线程间共享进程的地址空间

由于线程是阻塞式的,如果想要同步执行IO,每个IO都必须开启一个新线程,多线程开销较大,适合多任务处理,进程崩溃不影响其他进程,而线程只是一个进程的不同执行路线。

线程有自己的堆栈,却没有单独的地址空间进程死就等于所有线程死,所以多进程要比多线程健壮。但在进程切换时,消耗资源较大,效率较差。

线程是并发的,且是阻塞式同步的,一旦资源死锁,线程将陷入混乱。在同步线程的执行过程中,线程的执行切换是由CPU轮转时间片的分配来决定的

线程状态图

  • 新建状态:new创建一个线程时,还没开始运行,就是新建状态。
  • 就绪状态:新建后,调用start()方法,线程就处于就绪态,等待CPU调度。
  • 运行状态:当线程获得了CPU时间后,进入运行状态,执行run()里的内容
  • 阻塞状态:线程运行中随时可能被阻塞:比如调用sleep()方法;等待获取锁被阻塞;线程在等待其他触发条件。暂时让出CPU资源。
  • 死亡状态:有两个原因导致线程死亡:run()方法正常结束;一个未捕获的异常终止了run()方法

协程

Unity 中所有脚本运行公用一条主线程,而协程是开辟的伪线程。这个伪线程可以让一个方法分多次执行,相当于让主线程劈几个叉。

协程还称微线程,纤程,本质是一个单线程协程是比线程更轻量级的存在,协程不由操作系统内核所管理,而是完全由程序所控制(也就是在用户态执行)。

协程的好处是性能大幅提升,不会像线程切换那样消耗资源。同一时间只能执行某个协程,开辟多个协程开销不大适合对任务进行分时处理

协程有自己的寄存器和上下文栈。协程调度切换时,将寄存器和上下文栈保存到其他地方,并在协程切换回来时恢复之前保存的寄存器和上下文栈。由于直接对栈进行操作基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文切换非常快。

一个线程可以有多个协程,一个进程也可以单独拥有多个协程。线程和进程都是同步机制,而协程是异步机制,无需阻塞。协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用时的状态。多协程间对CPU的使用是依次进行的,每次只有一个协程工作,而其他协程处于休眠状态。

实际上多个协程是在一个线程中的,只不过每个协程对CPU进行分时。协程可以访问和使用Unity的所有方法和Component函数(子程序)的调用是通过栈实现的,一个线程就是执行一个函数,函数调用总是一个入口,一个返回,调用顺序是明确的,而协程在函数内部是可以中断的,然后转而执行其他函数,在适当的时候再返回来继续执行。函数(子程序)的切换不是由线程切换,而是程序自身控制,因此没有线程切换开销。和多线程相比,线程越多,协程的性能优势就越明显,切协程因为依次执行,不存在线程安全问题,变量访问不会冲突,共享资源也无需加锁,只需要判断状态即可,所以执行效率比线程高很多。

协程的语法

  • yield:暂停,通常用 yield return null 来暂停协程。
  • StartCoroutine(方法名()):恢复执行。
  • WaitForSeconds:引入时间延迟,默认情况下,协程将在 yield 后的帧上恢复。使用 yield return new WaitForSecond(.1f) 后,将延迟0.1秒后执行协程。

例如:

1
2
3
4
5
6
7
IEnumerator Start()
{
string url = "https://xxxx.xxxx.xxxx/xxxx.jpg";
WWW www = new WWW(url);
yield return WWW;
renderer.material.mainTexture = www.texture;
}

当程序执行到 yield return WWW; 时就不会直接往下执行了,而是等到网络请求结束后的第一帧的WWW协程节点触发时才继续执行,也就是说,当网络请求结束后,纹理才会被替换。

yield return对象

  • null或数字:在Update后执行,适合分解耗时的逻辑处理。
  • WaitForFixedUpdate:在FixedUpdate后执行,适合分解物理操作。
  • WaitForSeconds:在指定时间后执行,适合延迟调用。
  • WaitForSecondsRealtime:在指定时间后执行,适合延迟调用。不受时间缩放影响。
  • WaitForEndOfFrame:在每帧结束后执行,适合相机跟随操作。
  • Coroutine:在另一个协程执行完毕后再执行。
  • WaitUntil:在委托返回true时执行,适合等待某一操作。
  • WaitWhile:在委托返回false时执行,适合等待某一操作。
  • WWW:在请求结束后执行,适合加载数据,如文件、贴图、材质等。

尾声

通过并发编程,我们可以实现更复杂的游戏逻辑,如大规模场景中的动态光照计算、复杂物理效果的实时模拟等,而无需担心这些任务会拖慢游戏的主循环。此外,随着Unity对并发编程支持的不断增强,开发者们将有更多机会探索和利用现代硬件的并行处理能力,为玩家带来更加流畅、逼真的游戏体验。

从场景切换的异步加载到并发编程的深入探索,我学到了很多。通过掌握并发编程的精髓,我们不仅能够提升游戏的运行效率,还能为游戏设计带来更多可能性,让创意与技术的碰撞绽放出更加璀璨的光芒。

—end—