Asynchronous game development

在Notion中查看以获得更优秀的体验

前言

异步编程,一个看似用于网络/多线程的编程范式,在游戏开发中也可以大大增加代码书写的便捷性和可读性,本文将介绍这一点。

虽然我想网上应该已经有十一万四千五百一十四篇文章介绍了这个技巧,但是笔者最近亲身体会到了这一点,于是还是写一下吧。

本文将主要使用C#介绍。笔者默认读者有基础的C#异步相关的语法知识。

async/await之力

我们先来介绍一下什么是最基础的异步编程。

在各种编程语言中,你可以通过某些方式标注一个函数是异步的(在C#中,这是async关键字),执行异步函数时,你的主函数将会继续执行。例如:

public async Task Func(){...}
public async Task Main(){
    Func();
    Other(); //并不会等到Func执行完才会执行Other,而是两个“一起”执行
}

相对的,如果用了await,则会等待:

public async Task Func(){...}
public async Task Main(){
    await Func();
    Other(); //【会】等到Func执行完才会执行Other,而【不是】两个“一起”执行
}

异步,让跨帧逻辑更美好~

接下来我们将介绍如何通过await来实现轻松的跨帧逻辑。

场景1:弹幕STG

考虑你有一个弹幕boss,需要实现下列逻辑:“每60帧,发射一圈子弹”。

这是你写的代码,easy and simple:

public void Update(){ //called every frame
    frame++;
    if(frame%60==0){
        SpawnBullets();
    }
}

这很单调,而且太简单了,于是你想实现一个复杂的爆发弹幕逻辑:“boss休息60帧,发射一圈子弹,然后休息90帧,然后以每20帧的频率发射一圈子弹3次,循环”。

你需要维护现在是“一圈子弹”,还是“三圈子弹”的状态,在“三圈子弹”的状态中,你还要记录现在发射了几次。deepseek生成的代码如下:

public void Update()
{
    frameTimer++;

    switch (phase)
    {
        case Phase.Rest1:
            // 休息 60 帧后发射一圈
            if (frameTimer >= RestFrames1)
            {
                frameTimer = 0;
                SpawnBullets();
                phase = Phase.Rest2;
            }
            break;

        case Phase.Rest2:
            // 休息 90 帧后进入连发阶段
            if (frameTimer >= RestFrames2)
            {
                frameTimer = 0;
                burstFired = 1;
                SpawnBullets();     // 进入连发阶段时立即发射第一圈
                phase = Phase.Burst;
            }
            break;

        case Phase.Burst:
            // 每 20 帧发射一次,共 3 次
            if (frameTimer >= BurstInterval)
            {
                frameTimer = 0;
                burstFired++;

                if (burstFired < BurstTimes)
                {
                    SpawnBullets();
                }
                else
                {
                    // 3 次发射完毕,循环回到初始休息
                    burstFired = 0;
                    phase = Phase.Rest1;
                }
            }
            break;
    }
}

显然,这个代码很难维护,而且容易写错。(很可惜,有些弹幕系统只支持这样的写法,例如四聖龍神録)

因为,这是反直觉的写法!!你在把一个Sequential的逻辑强行摊平。如果使用异步编程,这个代码将会变得非常intuitive(by deepseek):

private async Task AttackLoop(CancellationToken ct)
{
    try
    {
        while (!ct.IsCancellationRequested)
        {
            // 1) 休息 60 帧,发射一圈
            await Frames(60, ct); //假设我们有这个函数,让我们等待x帧
            SpawnBullets();

            // 2) 休息 90 帧
            await Frames(90, ct);

            // 3) 每 20 帧发射一圈,共 3 次
            for (int i = 0; i < 3; i++)
            {
                SpawnBullets();
                if (i < 2)
                    await Frames(20, ct);
            }
        }
    }
    catch (OperationCanceledException)
    {
        // 正常取消,忽略
    }
}

启动它非常简单:

public void Start() //Called on first frame only
{
    cts = new CancellationTokenSource();
    _ = AttackLoop(cts.Token);   // fire-and-forget,异常需自行捕获
}

public void Stop() => cts?.Cancel();

什么是CancallationToken?

C#用这个token来通知你的上级函数有没有发出“请取消/终止这个操作”(在本文中的例子可能是boss死亡)的信号,正常情况下,你一直往下传,定时检查就好了。注意:这个token是“君子”协作式的,也就是你必须定期检查这个token,而不是期望C# magically终止你的函数。

CancellationToken好烦啊,我要层层传下去!

Kotlin的协程模型不需要传ct,快来用吧!加点料,然后就可以恭喜你发明了:Koto,一个基于Kotlin协程的弹幕射击游戏框架!另外,关于Koto和Kotlin协程的底层逻辑,欢迎阅读笔者的另一篇文章从CPS到Kotlin Coroutine基岩层探秘(难度较高!)

场景2:动画队列

如何让动画和效果合理的显示一直是一个常见的课题。在Slay the Spire里,采用的是名为“ActionQueue”的系统。一段动画和对应的数值变化被称作Action。游戏维护了一个ActionQueue,当玩家打牌时,就会往这个队列里追加action,action里的动画按照顺序执行,当执行完的时候pop掉。虽说是Queue,有些action还会往queue的前面插队元素(例如一个action里会有许多子action之类的)。这是一个很优秀的设计实现。

但是,你完全可以使用异步编程来实现这一点(哦,原来他们的Java没有await啊,那没事了。),包括嵌套的动画也会变得十分容易:

public async Task DamageAction(){
    await PrimeTween.xxxx(); //actual animation
    player.hp-=123;
    await PrimeTween.xxxx();
}

public async Task MyCardAction(){
    await DamageAction();
    await InflictPoisonAction();
}

public async Task MainAnimation(){
    await PlayCardAnimation();
    await MyCardAction();
    await PostCardPlayAnimation();
}

场景3:回合游戏

假设你需要设计这样的回合制RPG游戏:玩家选择一个技能使用,NPC也选择一个技能,然后根据速度之类的释放技能(例如勇者斗恶龙、宝可梦)

在这里,你可以用异步操作解耦UI和具体的战斗逻辑处理。假如你将具体的战斗逻辑理解成“服务器”,你可以将玩家选择技能使用视为一个“UI客户端”,假如二者分离,玩家选择技能就可以视为一个函数调用,而await就是他要耗时的标志。战斗逻辑中完全不需要知道怎么获得的这个技能。这也是一种RPC思想。

public async Task BattleLoop(){
    while(true){
        var skill = await UiManager.AskPlayerForSkill();
        skill.Cast(); //释放技能
    }
}

//在UiManager::AskPlayerForSkill里实现打开skill选择框,处理UI逻辑……当关闭选择框时,返回task的结果。

这样做的好处是,你可以利用一些标准库(例如“等待全部”“等待最快”“等待直到超时”)来加速逻辑的实现。

总结

那么,什么情况适合用异步编程呢?总结两条:
- 需要sequential的跨帧逻辑
- 需要状态机的时候

这么看来,有复杂一级二级菜单的UI也可以用异步编程实现(因为这些menu是状态机)!可以避免从三级菜单按了个back写错了不小心回到一级菜单之类的问题。

底层实现

许多游戏引擎已经有了现成的异步操作,例如Unity的PrimeTween动画系统是允许你await一个动画结束的。但是很多时候,我们可能需要wire自己的Task到一个或者多个引擎函数上。

Task

在大部分语言中,都是用的是Future and Promise并发模型(C#的Task,JS的Promise,Rust的Future)。在C#中,你等待的不是一个函数,而是一个Task,Task<T>是一种“期待”,表示在未来将会产生T的结果,但是是未来。在你await之前,他都“可能没有做完”。那一个Task是怎么知道自己什么时候完结了呢?在C#里使用的是TaskCompletionSource。当这个Source.SetResult()的时候就会通知正在等待对应Task的地方我的Task做完了,可以继续了。(详细请见下方示例)

理解了这一点后,你就知道为什么async的函数返回值必须是一个Task(或者类似物),而且你可以写:

public async Task Func(){...}
public async Task Main(){
    var task = Func();
    Other(); //并不会等到Func执行完才会执行Other,而是两个“一起”执行
    await task; //等待Func剩下的部分跑完
    Other2();
}

在游戏引擎里接入Task

我们以一个最简单的例子为例:写一个异步函数NextFrame(),它的作用是当await NextFrame()时,等待到下一帧。假设我们的引擎只给了我们一个Update()接口,他是每帧调用的。

//By DeepSeek
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

/// <summary>
/// 帧调度器:外部每帧调用 Update(),内部提供 await NextFrame()
/// </summary>
public class FrameScheduler
{
    private readonly Queue<TaskCompletionSource<bool>> _waiters = new();

    // 每帧调用一次
    public void Update()
    {
        // 先取快照:本帧新加入的等待者(例如 continuation 里又 await 了一次)留到下一帧
        int count = _waiters.Count;
        for (int i = 0; i < count; i++)
        {
            _waiters.Dequeue().SetResult(true);
        }
    }

    // 等到下一帧
    public Task NextFrame()
    {
        var tcs = new TaskCompletionSource<bool>();
        _waiters.Enqueue(tcs);
        return tcs.Task;
    }
}

是不是很简单,只需要一些infra work就可以将事件驱动转换成我们的异步编程?类似的,上文提到的UiManager.AskPlayerForSkill()也可以这么实现:保存一个TaskCompletionSource,然后等用户选完了找到保存的TCS然后SetResult就可以了。

考量点

凑字数的辨证段落:什么时候我不应该使用async/await?

答:你追求zero-allocation的时候。你看,使用async await需要大量创建对象(不然他怎么知道你的async函数执行到哪里了?肯定要有地方保存当前位置),而对象可能会对Garbage Collection带来压力。另外,C#也不是零成本抽象,显然会带来一些overhead。另外,如果没有处理好cancellation问题,容易导致某些僵尸代码一直在后台执行着,或者是无法终止。

版权声明:
作者:XGN
链接:https://blog.hellholestudios.top/archives/2097
来源:Hell Hole Studios Blog
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
< <上一篇
下一篇>>