公司里我所在的小组每两周会安排人轮流在组会上进行分享(也没这么规律)。内容以技术为主,比如纯技术,或者比较通用/有特色的业务,或者相关的前沿领域(也有讲游戏设计的)。由于没在做什么很通用的模块,对各种东西也没有很硬的了解,我基本上就介绍一下UE的某个业务模块,主要是怎么用,大致思路是什么。

上班以来我已经做了两次分享了(虽然看人数其实不应该这么频繁),就在这里留个档。当然不会把做的各种资料直接放出来,主要是把提纲整理一下备忘。

后续如果我有更多的分享也会继续在博客里同步一份。

这次分享的时候我已经很久没做什么大活了,基本上就一直做一些琐碎事情。上半年虽然有个做了很长时间的活,但就是个复杂且零散的特定业务,感觉分享出来对其他人没什么帮助。自从上班后我也没额外学什么新东西。所以这次就正好打算讲一讲上学时候的老本了。

这次准备了两个目录配图,前面用的都是第一个,总结的时候用的是宝可梦历代 NPC “科学胖子” 的: “科技的力量真伟大!”。虽然是很简单的一句台词,不过我莫名觉得很有分量,既能从中感受到游戏世代沉甸甸的历史,又有一种在向前看的感觉。

本来只打算讲深度学习基础,不过为了让分享更前沿一点,我就看了当下的商业引擎有没有相关的功能。因为在印象中,一提到游戏引擎的 AI,就是像行为树、寻路等工具,或者各种更傻瓜式的 AI Agent。没想到 UE 在 5.2 就发布了 NNE 这样一个直接在神经网络层面提供支持的官方功能,后续也在力推。再次感叹 UE 确实太前沿了。

所以我就把分享内容安排成两部分:

第一部分先以 CNN 为例讲深度学习基础,然后讲 Transformer 和 ChatGPT。刚开始打算一个个领域来介绍概念和经典模型(CV、NLP、生成式AI、强化学习),不过工作量有点大,而且这适合给学校里的人讲,对公司同事没有任何意义,还是只介绍一下深度学习基本概念就可以了。然后讲 Transformer 是因为这个对每个人的工作都有影响,以 AI 为主题的分享不讲这个说不过去。(这部分我也是速成的。前面深度学习基础很多我也记不得了,刚好翻了一下博客之前的笔记。)

第二部分介绍 UE NNE,主要是看看 UE 是以怎么样的思路对当下的 AI 提供支持的。此外我用最新版本的 UE 5.4.3 做了一个 demo 可以直接演示一下。其实这部分才应该是重点,不过如果不讲前面的深度学习基础,这部分也无从谈起。

好像是到了什么谜之逆境期,今年 7、8 月工作和生活实在都是鸡飞狗跳的,心情非常难过😔。总之直到前一天才把 PPT 做好,比较潦草。老实说这类偏向特定领域的内容对听者的工作不会有任何帮助,很大程度上就是图个乐,不过我个人对这次的内容还是挺满意的。可能也是因为工作做的东西太让我心烦了,下班又只想躺尸,以此为契机去接触点新东西也挺好。

1 现代 AI 基础

现代 AI 概述

提到游戏 AI 其实游戏程序员第一时间想到的都是行为树之类的制作 boss AI 的工具,由人类设定 boss 在什么情况下做出什么样的反应。

我把所谓的“传统” AI、“现代” AI 划分为:基于策略和基于学习。现代 AI 从输入到输出的对应关系不是我们直接制定规则,而是给机器数据,机器自己从数据中学习这个对应关系。当学习完成后,机器就可以根据当前的输入得到一个输出。

这个关系可以很复杂,但学习的过程和结果也相对不可控。

深度学习基础(CNN 为例)

主要介绍了下面这些东西,基本上省略一切细节,只直观讲最基本的概念:

  • 深度学习:把对应关系表示为一个具有一定深度的人工神经网络,通过一些技术对其求解
  • 连接主义学派和神经元:神经网络的基本单位“神经元”
  • 激活函数:如果全是线性计算,那么叠再多层也可以用一个很简单的式子写出来
  • 人工神经网络:MLP 模型,训练和推理是什么(前向传播和反向传播的概念,不讲反向传播具体怎么算的,其实我也早忘了)
  • 卷积神经网络
    • 展示一下在各种资料中经常会看到的 CNN 结构图。介绍一下几大件(胡浩基把 CNN 总结为 CBAPD 五大件,我把全连接也加进来了,总结为 CBAPDF 。我觉得这样很简单明了。其中 A 和 F 是前面介绍过的,所以后面就是剩下的几个)
    • 二维卷积运算和卷积层(C)
    • 池化层:减少数据量的同时提取特征(P)
    • 归一化:让数据分布在激活函数梯度大的位置(B)
    • dropout:其实这个跟 CNN 关系不大(D)
    • 这次来看一下花花绿绿的图到底画的是什么,其实三个都是 AlexNet。知道上面几大件后,就可以看懂这些神经网络图了

这部分虽然篇幅不短不过没什么好放的,详细可以看之前我做的网课笔记的文章。

ChatGPT(Transformer)

其实我也没仔细看过 ChatGPT 的资料,所以准备内容的时候我是看李宏毅的网课 + 李沐读论文视频速成的。(上面深度学习基础这部分我也忘的差不多了,还翻了下博客里之前的笔记来看。很多图直接用的毕业论文的。)

李宏毅每年的课竟然都更新学界、业界的新内容,今年还开了新的《生成式AI导论》,我哭死。我直接用了很多李宏毅网课的图。

内容如下:

  • Transformer
    • Encoder-Decoder 架构:长度可变的输入序列 => 固定形状的编码 => 长度可变的输出序列
    • Seq2Seq 问题:机器翻译、AI 对话都是典型的输入输出都是不定长序列的问题
    • Attention 机制:最先将 attention 引入 Seq2Seq,解决中间向量丢失信息的问题。后续研究发现可以只使用 attention 提取特征,而将 RNN 舍弃。基于此提出了 Transformer
    • Transformer
      • 文字转为Token,理解Token(input embedding)
      • transformer block:多头注意力是在干啥
  • ChatGPT,这部分就是聊聊天了

    • GPT 是什么:token接龙
    • GPT 的学习:训练数据量,模型参数量
    • ChatGPT
  • 补充

    • 框架和工具:PyTorch / Tensorflow,Python + Conda + Jupyter
    • 研究领域:应用领域和研究领域
    • 现代 AI 的未来:这里稍微提了自己的一些 insight
      • 未来趋势:重要性上prompt👆,实现细节👇
      • 当下关注:用成熟的 AI 工具跨越模态,把难题转移到舒适区

2 游戏引擎对现代 AI 的支持(这里才是重点!)

游戏引擎中的 AI

提到游戏引擎和 AI,程序员印象中都是各种 gameplay agent 工具,Unity 最新推的也是 ML 加持的 gameplay agent。此外还有正在发展的 PCG 等等。

可以看到都是把 AI 封装到应用层面,再提供给用户。有没有可能直接从神经网络这个层面就提供支持呢?

这要考虑的事情就多了。游戏引擎中的 AI 可以用在各种各样的业务中,典型的一种是:游戏一帧渲染结束后,再通过 AI 进行一个风格化的处理。AI 和渲染可以说是当下显卡最主要的两大战场,如果在一个渲染帧内要都进行计算,显然需要跟渲染管线进行一些配合。到了其他业务模块也是一样的。

UE5 NNE

UE 5.4 的 release note 里有这样一条:Neural Network Engine 从 experimental 转为 beta 啦!

NNE 实际上就是 UE 目前对神经网络提供支持的解决方案。支持 in-editor/in-game,on cpu/on gpu,用来 load and efficiently run 用户的 pretrained 的神经网络模型。

release note 里写道可以把 NNE 当作 AI 领域的 RHI,屏蔽各种 runtime 的细节,提供一个统一的接口。

我去翻了一下 NNE 的前世今生,最早在 UE 4.26 就有一个同名的插件,来自一个日本小团队。现在这个插件依然在售,不过 UE 5.2 官方上架了同名免费插件,而且看架构图感觉思路大差不差,可能确实是有什么交易,估计是转正了。

总之 UE 官方对这一套开发的很勤,推的也很快,5.4 其中一个 runtime 插件就从 experimental 转为了 beta。目前文档没有功能推的快,有一篇 Overview 介绍关键概念但我看下来应该还是 5.2 的东西,不过毕竟是官方文档,在概念上还是以这个为准来介绍吧。还有一个写在官方社区的 Quick Start Guide 讲基本用法,这个只到 5.3,所以如果用 UE 5.4 的话只看这个估计是做不出来 demo 的。

详细的概念可以查阅官方文档,主要多提一下 runtime 部分。NNE 把使用神经网络的业务分为三种,也对应了提供的不同的 runtime:

  • INNERuntimeCPU
    • 在 CPU 上运行推理,CPU 内存中提供输入和输出
    • 推理可以在游戏线程上同步运行,也可以在后台的异步任务内运行
  • INNERuntimeGPU
    • GPU 上进行推理,输入、输出 tensor 作为 CPU 内存提供,并需要与 GPU 同步,由 runtime 进行上传和下载
    • 只能在编辑器中使用(通常用于执行编辑器内资产操作),GPU 推理独立于帧的渲染,但会跟渲染管线争用 GPU 资源
    • 与 INNERuntimeCPU 类似,可以从任何线程运行,只要调用者负责线程安全和内存生命周期即可
  • INNERuntimeRDG
    • 在 RGD(Render Dependency Graph)上求值,输入、输出 tensor 必须提供为 RDG buffer
    • 适用在神经网络消耗/产生渲染帧使用的资源的场景(也就是本章开头我举的那个例子)
    • 把神经网络的求值也 enqueue 到 render graph 中。求值在 GPU 上进行,从渲染线程调用,模型的创建、设置在游戏线程完成

由于从 5.3 到 5.4 又有一些改动,我做 demo 刚开始卡住了一段时间,还好后面又搜到一篇文章,里面链到一个讲 5.4 最新架构的资料,结果点进去发现就是最开始开发 NNE 的日本团队在今年大阪 UE Meetup 上做的分享 。还好有这个分享 + 这篇博文让我把 demo 做出来了。

demo 参考的博客:https://hogetatu.hatenablog.com/entry/2024/06/30/230620

日本团队大阪 UE Meetup 上的分享

先来看看 5.4 最新的架构, 左上角是可用的各个 runtime,跟图中蓝色加粗箭头对应。NNERuntimeORT 就是转为 beta 的功能。

NNE 实例(UE 5.4.3)

在游戏内做一个简单的手写数字识别,每隔几秒从测试集中随机取图片,传给预训练的神经网络,在 CPU 上进行求值,然后把结果展示出来。

预训练的 ONNX 模型可以在 https://github.com/onnx/models 获取。可以在 https://netron.app/ 查看模型的 input output 结构。可以看到我用的 mnist-12 模型的输入是单通道(1x1x28x28)的,所以要在引擎里把所有图片设置为 R32F,所以字看上去是红色的。

先来看看 Actor 的蓝图,主要是用来做展示的,把输入给神经网络的图片和输出的 10 个数字的概率打印出来。也就是一个 TextRender + 一个 Billboard,逻辑写在蓝图里即可。

代码这边要存一个模型和一组测试集,RunNNE() 就是取一张图片进行推理,在蓝图 tick 的时候调它,过程中会调 OnStart() 和 OnStop(),也就是前面说的蓝图分别展示输入(随机取的图片)和推理结果(10 个数的概率)。

主要代码如下,结构上跟官方文档相同,完整的可以到上面那篇文章里找,基本相同。

#include "NNE.h"
#include "NNEModelData.h"
#include "NNERuntimeCPU.h"
#include "Engine/Texture2D.h"

UCLASS()
class NNEDEMO_API ANNEActor : public AActor
{
    GENERATED_BODY()
    
public:
    UFUNCTION(BlueprintCallable, Category = "NNEActor")
    void RunNNE();

    UFUNCTION(BlueprintImplementableEvent, Category = "NNEActor")
    void OnStartRunNNE(const UTexture2D* input_texture);

    UFUNCTION(BlueprintImplementableEvent, Category = "NNEActor")
    void OnStopRunNNE(const TArray<float>& output_data);
  
private:
    void SetUpNNE();
    void DestroyNNE();  
    
public:
    UPROPERTY(EditAnywhere, meta = (DisplayName = "NNE Model Data"))
    TObjectPtr<UNNEModelData> m_nne_model_data;

    UPROPERTY(EditAnywhere)
    TArray<TObjectPtr<UTexture2D>> m_input_textures;
};

推理用的是 NNERuntimeORTCpu,也就是转为 beta 的插件在 CPU 上推理的那个 runtime。可以回上面那个图看看是哪一条线。

void ANNEActor::SetUpNNE()
{
    FString use_runtime_str = FString("NNERuntimeORTCpu");
    TWeakInterfacePtr<INNERuntimeCPU> nne_runtime = UE::NNE::GetRuntime<INNERuntimeCPU>(use_runtime_str);

    m_model = nne_runtime->CreateModelCPU(m_nne_model_data);

    m_model_instance = m_model->CreateModelInstanceCPU();
}

RunNNE() 里还是像上面说的,OnStart() 随机取一张图片给蓝图,异步发起神经网络的求值,求值结束后再回到游戏线程调用 OnStop(),把结果传给蓝图。

void ANNEActor::RunNNE()
{{
    m_model_helper->m_model_instance = m_model_instance;
    m_model_helper->m_is_running = true;
    
    OnStartRunNNE(m_model_helper->m_input_texture.Get());
    
    AsyncTask(ENamedThreads::AnyNormalThreadNormalTask, [weak_this, model_helper]()
        {
    	    // 准备输入输出
            
            model_helper->m_model_instance->RunSync(inputs, outputs);

            AsyncTask(ENamedThreads::GameThread, [weak_this, model_helper]()
                {
                    model_helper->m_is_running = false;

                    if (weak_this.IsValid())
                    {
                        weak_this->OnStopRunNNE(model_helper->m_output_data);
                    }
                });
        });
}

详细如下,官方文档 5.3 的 Quick Start Guide,input/output tensor 的尺寸貌似还需要手动指定,如果场景简单的话,5.4 就没这个必要了。

void ANNEActor::RunNNE()
{
    m_model_helper->m_model_instance = m_model_instance;
    m_model_helper->m_is_running = true;

    OnStartRunNNE(m_model_helper->m_input_texture.Get());

    TWeakObjectPtr<ANNEActor> weak_this = this;
    AsyncTask(ENamedThreads::AnyNormalThreadNormalTask, [weak_this, model_helper = m_model_helper]()
        {
            TConstArrayView<UE::NNE::FTensorDesc> input_tensor_descs = model_helper->m_model_instance->GetInputTensorDescs();
            checkf(input_tensor_descs.Num() == 1, TEXT("The current example supports only models with a single input tensor"));
            UE::NNE::FSymbolicTensorShape symbolic_input_tensor_shape = input_tensor_descs[0].GetShape();
            checkf(symbolic_input_tensor_shape.IsConcrete(), TEXT("The current example supports only models without variable input tensor dimensions"));
            TArray<UE::NNE::FTensorShape> input_tensor_shapes = { UE::NNE::FTensorShape::MakeFromSymbolic(symbolic_input_tensor_shape) };

            model_helper->m_model_instance->SetInputTensorShapes(input_tensor_shapes);

            TConstArrayView<UE::NNE::FTensorDesc> output_tensor_descs = model_helper->m_model_instance->GetOutputTensorDescs();
            checkf(output_tensor_descs.Num() == 1, TEXT("The current example supports only models with a single output tensor"));
            UE::NNE::FSymbolicTensorShape symbolic_output_tensor_shape = output_tensor_descs[0].GetShape();
            checkf(symbolic_output_tensor_shape.IsConcrete(), TEXT("The current example supports only models without variable output tensor dimensions"));

            model_helper->m_input_bindings.SetNumZeroed(1);
            model_helper->m_output_bindings.SetNumZeroed(1);

            // Fill the input tensor with the input texture data
            FTexture2DMipMap& mip = model_helper->m_input_texture->GetPlatformData()->Mips[0];
            model_helper->m_input_bindings[0].Data = mip.BulkData.Lock(LOCK_READ_ONLY);
            model_helper->m_input_bindings[0].SizeInBytes = mip.BulkData.GetBulkDataSize();

            // Allocate memory for the output tensor
            model_helper->m_output_data.SetNumUninitialized(10);
            model_helper->m_output_bindings[0].Data = model_helper->m_output_data.GetData();
            model_helper->m_output_bindings[0].SizeInBytes = model_helper->m_output_data.Num() * sizeof(float);

            // Run the model
            UE::NNE::IModelInstanceCPU::ERunSyncStatus RunSyncStatus = model_helper->m_model_instance->RunSync(model_helper->m_input_bindings, model_helper->m_output_bindings);
            if (RunSyncStatus != UE::NNE::IModelInstanceCPU::ERunSyncStatus::Ok)
            {
                UE_LOG(LogTemp, Error, TEXT("Failed to run the model"));
            }

            mip.BulkData.Unlock();

            AsyncTask(ENamedThreads::GameThread, [weak_this, model_helper]()
                {
                    model_helper->m_is_running = false;

                    if (weak_this.IsValid())
                    {
                        weak_this->OnStopRunNNE(model_helper->m_output_data);
                    }
                });
        });
}

3 推荐资料

推荐资料

.onnx 模型:

UE NNE