• StartCoroutine/yield 返回模式在 Unity 中到底如何工作?


    Unity3D协程详解

    游戏中的许多过程都是在多个帧的过程中发生的。你有“密集”的过程,比如寻路,每个帧都努力工作,但会分成多个帧,以免对帧速率产生太大影响。您拥有“稀疏”进程,例如游戏触发器,它们在大多数帧中不执行任何操作,但偶尔会被要求执行关键工作。两者之间有各种各样的流程。

    每当您创建一个将在多个帧上进行的进程(无需多线程)时,您需要找到某种方法将工作分解为可以每帧运行一个的块。对于任何具有中心循环的算法,这是相当明显的:例如,可以构造 A* 探路者,使其半永久地维护其节点列表,每帧仅处理打开列表中的少数节点,而不是尝试一口气完成所有工作。需要进行一些平衡来管理延迟 - 毕竟,如果您将帧速率锁定在每秒 60 或 30 帧,那么您的过程每秒只会执行 60 或 30 个步骤,这可能会导致该过程仅执行整体太长了。一个简洁的设计可能会在一个级别上提供尽可能最小的工作单元——例如处理单个 A* 节点——并在顶部分层一种将工作分组为更大块的方法——例如继续处理 A* 节点 X 毫秒。(有些人称之为“时间切片”,但我不这么认为)。

    尽管如此,允许以这种方式分解工作意味着您必须将状态从一帧转移到下一帧。如果您要分解迭代算法,那么您必须保留迭代之间共享的所有状态,以及跟踪下一步要执行哪个迭代的方法。这通常不算太糟糕——“A*探路者类”的设计相当明显——但也有其他情况,不太令人愉快。有时,您将面临长时间的计算,这些计算在帧与帧之间执行不同类型的工作;捕获其状态的对象最终可能会产生一大堆半有用的“局部变量”,这些“局部变量”用于将数据从一帧传递到下一帧。如果您正在处理稀疏进程,您通常最终不得不实现一个小型状态机,只是为了跟踪工作何时应该完成。

    如果您不必在多个帧中显式跟踪所有这些状态,也不必使用多线程并管理同步和锁定等,而只需将函数编写为单个代码块,那么这不是很简洁吗?标记函数应该“暂停”并稍后继续的特定位置?

    Unity 以及许多其他环境和语言以协程的形式提供了这一点。

    他们看起来怎么样?在“Unityscript”(Javascript)中:

    function LongComputation()
    {
    while(someCondition)
    {
    /* Do a chunk of work */

        // Pause here and carry on next frame
        yield;
    }
    
    • 1
    • 2
    • 3

    }
    在 C# 中:

    IEnumerator LongComputation()
    {
    while(someCondition)
    {
    /* Do a chunk of work */

        // Pause here and carry on next frame
        yield return null;
    }
    
    • 1
    • 2
    • 3

    }
    它们如何工作?我只想说,我不为 Unity Technologies 工作。我还没有看到Unity源代码。我从未见过 Unity 协程引擎的内部结构。但是,如果他们以与我将要描述的方式完全不同的方式实现它,那么我会感到非常惊讶。如果来自 UT 的任何人想要插话并谈论它的实际工作原理,那就太好了。

    重要线索在 C# 版本中。首先,请注意该函数的返回类型是 IEnumerator。其次,请注意其中一个语句是yield return。这意味着yield 必须是一个关键字,并且由于Unity 的C# 支持是vanilla C# 3.5,因此它必须是vanilla C# 3.5 关键字。事实上,它在 MSDN 中- 谈论称为“迭代器块”的东西。发生什么了?

    首先,有 IEnumerator 类型。IEnumerator 类型的作用类似于序列上的光标,提供两个重要成员:Current,它是一个属性,为您提供光标当前所在的元素;MoveNext(),一个移动到序列中下一个元素的函数。因为 IEnumerator 是一个接口,所以它没有具体指定这些成员是如何实现的;MoveNext() 可以只向 Current 添加一个,或者可以从文件加载新值,或者可以从 Internet 下载图像并对其进行哈希处理,然后将新哈希值存储在 Current 中……或者它甚至可以首先做一件事序列中的元素,而第二个元素则完全不同。如果您愿意,您甚至可以使用它来生成无限序列。MoveNext() 计算序列中的下一个值(如果没有更多值,则返回 false),Current 检索它计算的值。

    通常,如果您想实现一个接口,您必须编写一个类,实现成员,等等。迭代器块是实现 IEnumerator 的一种便捷方法,没有那么多麻烦 - 您只需遵循一些规则,IEnumerator 实现就会由编译器自动生成。

    迭代器块是一个常规函数,它 (a) 返回 IEnumerator,并且 (b) 使用yield 关键字。那么yield关键字实际上是做什么的呢?它声明序列中的下一个值是什么——或者没有更多的值。代码遇到yield return X 或yield break 的点就是IEnumerator.MoveNext() 应该停止的点;yield return X 会导致 MoveNext() 返回 true,并且 Current 被分配值 X,而yield break 会导致 MoveNext() 返回 false。

    现在,这就是窍门。序列返回的实际值是什么并不重要。您可以重复调用MoveNext(),并忽略Current;计算仍将被执行。每次调用 MoveNext() 时,迭代器块都会运行到下一个“yield”语句,无论它实际生成什么表达式。所以你可以写这样的东西:

    IEnumerator TellMeASecret()
    {
    PlayAnimation(“LeanInConspiratorially”);
    while(playingAnimation)
    yield return null;

    Say(“I stole the cookie from the cookie jar!”);
    while(speaking)
    yield return null;

    PlayAnimation(“LeanOutRelieved”);
    while(playingAnimation)
    yield return null;
    }
    您实际编写的是一个迭代器块,它生成一长串空值,但重要的是它计算空值的工作的副作用。您可以使用如下简单循环来运行此协程:

    IEnumerator e = TellMeASecret();
    while(e.MoveNext()) { }
    或者,更有用的是,您可以将其与其他工作混合在一起:

    IEnumerator e = TellMeASecret();
    while(e.MoveNext())
    {
    // If they press ‘Escape’, skip the cutscene
    if(Input.GetKeyDown(KeyCode.Escape)) { break; }
    }
    正如您所看到的,每个yield return 语句都必须提供一个表达式(如null),以便迭代器块有一些内容可以实际分配给IEnumerator.Current。一长串空值并不完全有用,但我们对副作用更感兴趣。我们不是吗?

    实际上,我们可以用这个表达式做一些方便的事情。如果我们不只是产生 null 并忽略它,而是产生一些指示我们何时需要做更多工作的东西,该怎么办?当然,我们通常需要直接继续下一帧,但并非总是如此:很多时候我们希望在动画或声音播放完毕后,或者在经过特定时间后继续进行。那些 while(playingAnimation) 产生返回 null;构造有点乏味,你不觉得吗?

    Unity 声明了 YieldInstruction 基类型,并提供了一些具体的派生类型来指示特定类型的等待。您有 WaitForSeconds,它会在指定的时间过后恢复协程。您有 WaitForEndOfFrame,它可以在同一帧稍后的特定点恢复协程。您已经获得了协程类型本身,当协程 A 产生协程 B 时,它会暂停协程 A 直到协程 B 完成。

    从运行时的角度来看,这是什么样的?正如我所说,我不为 Unity 工作,所以我从未见过他们的代码;但我想它可能看起来有点像这样:

    List unblockedCoroutines;
    List shouldRunNextFrame;
    List shouldRunAtEndOfFrame;
    SortedList shouldRunAfterTimes;

    foreach(IEnumerator coroutine in unblockedCoroutines)
    {
    if(!coroutine.MoveNext())
    // This coroutine has finished
    continue;

    if(!coroutine.Current is YieldInstruction)
    {
        // This coroutine yielded null, or some other value we don't understand; run it next frame.
        shouldRunNextFrame.Add(coroutine);
        continue;
    }
    
    if(coroutine.Current is WaitForSeconds)
    {
        WaitForSeconds wait = (WaitForSeconds)coroutine.Current;
        shouldRunAfterTimes.Add(Time.time + wait.duration, coroutine);
    }
    else if(coroutine.Current is WaitForEndOfFrame)
    {
        shouldRunAtEndOfFrame.Add(coroutine);
    }
    else /* similar stuff for other YieldInstruction subtypes */
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    }

    unblockedCoroutines = shouldRunNextFrame;
    不难想象如何添加更多的 YieldInstruction 子类型来处理其他情况 - 例如,可以添加对信号的引擎级支持,并使用 WaitForSignal(“SignalName”)YieldInstruction 支持它。通过添加更多的 YieldInstructions,协程本身可以变得更具表现力 - 如果你问我,yield return new WaitForSignal(“GameOver”) 比 while(!Signals.HasFired(“GameOver”)) 更容易阅读事实上,在引擎中执行此操作可能比在脚本中执行速度更快。

    一些不明显的后果 关于这一切,有一些人们有时会忽略的有用的事情,我认为我应该指出。

    首先,yield return 只是产生一个表达式——任何表达式——而 YieldInstruction 是一个常规类型。这意味着您可以执行以下操作:

    YieldInstruction y;

    if(something)
    y = null;
    else if(somethingElse)
    y = new WaitForEndOfFrame();
    else
    y = new WaitForSeconds(1.0f);

    yield return y;
    特定的行 yield return new WaitForSeconds()、yield return new WaitForEndOfFrame() 等很常见,但它们本身并不是特殊形式。

    其次,因为这些协程只是迭代器块,所以如果您愿意,您可以自己迭代它们 - 不必让引擎为您做这件事。我之前用它来向协程添加中断条件:

    IEnumerator DoSomething()
    {
    /* … */
    }

    IEnumerator DoSomethingUnlessInterrupted()
    {
    IEnumerator e = DoSomething();
    bool interrupted = false;
    while(!interrupted)
    {
    e.MoveNext();
    yield return e.Current;
    interrupted = HasBeenInterrupted();
    }
    }
    第三,您可以在其他协程上让出这一事实可以让您实现自己的 YieldInstructions,尽管性能不如引擎实现的那样。例如:

    IEnumerator UntilTrueCoroutine(Func fn)
    {
    while(!fn()) yield return null;
    }

    Coroutine UntilTrue(Func fn)
    {
    return StartCoroutine(UntilTrueCoroutine(fn));
    }

    IEnumerator SomeTask()
    {
    /* … /
    yield return UntilTrue(() => _lives < 3);
    /
    … */
    }
    然而,我真的不推荐这样做——启动一个协程的成本对我来说有点沉重。

    结论 我希望这能够澄清您在 Unity 中使用协程时实际发生的一些情况。C# 的迭代器块是一个绝妙的小构造,即使您不使用 Unity,也许您会发现以同样的方式利用它们很有用。

  • 相关阅读:
    基于STM32设计的宠物投喂器
    OpenCV图像处理——获取穿过圆的直线与圆相交的两个点
    uniapp 小程序 堆叠轮播图 左滑 右滑 自动翻页 点击停止自动翻页
    Python和Excel的完美结合:常用操作汇总
    如何评价一门编程语言的难易程度?
    微前端架构的几种技术选型
    在node中实现高效率、低内存的excel/JSON转换
    java计算机毕业设计在线教育系统源代码+系统+数据库+lw文档
    操作系统的基本特征
    nginx 做转发处理,指定目录访问和tp同级的目录
  • 原文地址:https://blog.csdn.net/qq_42980269/article/details/134013779