在本文中,我们将深入探讨如何使用Unity3D和TensorFlow来教AI执行简单的游戏任务:投篮。完整的源代码可以在文末访问Github链接。
游戏简介
有一个游戏,玩家只有一个主要目标:把球投进篮筐。这听起来并不那么难,但是当你的血液告诉流动,心脏疯狂跳动,观众高声喝彩时,可能很难。在这里,我并不讨论经典的美式篮球,而是经典的Midway街机游戏NBA Jam。
如果你曾经玩过NBA Jam或者它授权的任何一个游戏,那么从球员的角度来看,你知道射球的机制非常简单。你只需在完美的时机按下投篮按钮。你有没有想过这个投篮从游戏的角度是如何选择的?如何选择球的弧度?投球有多难?计算机如何知道投篮的角度?
如果你是一个聪明的,喜欢数学的人,你可以用动手算出这些答案,但本人未能通过代数8级,所以......我不能用这种方法解决问题。我需要以不同的方式解决这个问题。而不是采取更简单,更快,更有效的实际做数学运算的路线,我们探探这个问题到底多难,学习一些简单的TensorFlow,并尝试投篮。
入门
我们需要一些准备才能完成这个项目。
- 统一模拟与现实中的篮球运动
- 用于训练我们模型的Node.js和TensorFlow.js
- TensorFlowSharp用于通过ML-Agents资源包在Unity中嵌入我们的模型
- tsjs-converter用于将TensorFlow.js模型转换为我们可以在Unity中使用的图。
- Google表格可轻松可视化我们的线性回归
即使你不是这些技术的专家,也完全可以!(我绝对不是这方面的专家!)我会尽力解释它们是如何组合在一起的。使用这么多不同技术的缺陷是我无法详细解释所有内容,但我会尝试尽可能地链接到教育资源!
下载项目
我不会尝试逐步重新创建这个项目,因此我建议在Github上下载源代码,然后我会解释发生了什么。
注意:你需要为Tensorflow 下载ML-Agents Unity资源包的导入,才能在C#中使用。如果你在Unity中找不到有关Tensorflow的任何错误,请确保你已遵循TensorflowSharp的Unity安装文档。
我们的目标是什么?
为了简单起见,我们对这个项目的期望结果非常简单。我们想要解决:如果射手距离篮筐距离为X,用的投篮的力量Y,就这些!我不会尝试进行瞄准。我只想弄清楚投球有多难。
如果你对如何在Unity中制作更复杂的AI感兴趣,你应该查看Unity中更完整的ML-Agents项目。我将在这里讨论的方法设计的简单易懂,并不一定是最佳示例。
ML-Agents:https://github.com/Unity-Technologies/ml-agents
篮筐和球
我们已经讨论了我们的目标:投进篮筐。要将一个球投近篮筐,你首先要有一个篮筐和一个球。这是我们就要用到Unit。
如果不熟悉Unity,你只要知道它是一个游戏引擎,可以让你为所有平台构建2D和3D游戏。它内置了物理的,基础的3D建模和一个很不错的脚本运行环境(Mono),使我们可以用C#编写游戏。
我没什么艺术细胞,只能拖着一些块把这个场景拼凑了起来。
那块红色块代表我们的玩家。篮球框设置有隐形触发器,允许我们检测物体(球)何时通过篮筐。
在Unity编辑器中,你可以看到以绿色标出的隐形触发器。注意,这里有两个触发器,这样我们就可以确保我们只计算从顶部到底部落到篮筐的球。
如果我们来看看在/Assets/BallController.cs中的OnTriggerEnter方法(我们的篮球的每个实例都会有的脚本),你可以看到这两个触发器怎样配合使用。
private void OnTriggerEnter(Collider other)
{
if (other.name == "TriggerTop")
{
hasTriggeredTop = true;
} else if (other.name == "TriggerBottom") {
if (hasTriggeredTop && !hasBeenScored)
{
GetComponent().material = MaterialBallScored;
Debug.Log(String.Format("{0}, {1}, {2}", SuccessCount++, Distance, Force.y));
}
hasBeenScored = true;
}
}
首先,这个函数要确保顶部和底部的触发器被击中,那么它改变了球的性质(其实就是颜色),所以我们可以直观的看到球被投入篮筐,最后,它注销我们关心的两个关键变量distance和force.y。
投篮
打开/Assets/BallSpawnerController.cs。这是一个生成我们的射手,产生篮球并尝试投篮的脚本。请查看DoShoot()方法末尾处的这个片段。
var ball = Instantiate(PrefabBall, transform.position, Quaternion.identity);
var bc = ball.GetComponent();
bc.Force = new Vector3(
dir.x * arch * closeness,
force,
dir.y * arch * closeness
);
bc.Distance = dist;
这个代码Instantiates是一个球的新实例,然后设置我们射击的力度和距目标的距离(所以我们可以稍后更容易地记录下来)。
在/Assets/BallController.cs中,也可以查看我们的Start()方法。在我们创建新篮球时调用此代码。
void Start ()
{
var scaledForce = Vector3.Scale(Scaler, Force);
GetComponent().AddForce(scaledForce);
StartCoroutine(DoDespawn(30));
}
换句话说,我们创造一个新的球,给它一些力,然后在30秒后自动销毁这个球,因为我们将要处理很多球,我们要确保一切都是合理的。
让我们来试试,看看我们的全明星射手是怎么做的。你可以点击Unity编辑器中的▶(播放)按钮,我们会看到如下:
我们的球员,我们可以称之为“Red”,几乎准备好迎战斯蒂芬库里。
但是,为什么Red表现如此糟糕?答案在于Assets/BallController.cs的float force = 0.2f这行。这行要求每一个投篮完全相同。如你所见,Unity直接地采用了它。所以才一次又一次地重复。
这当然不是我们想要的。如果我们从不进行尝试,我们永远学不会像詹姆斯那样的投篮,所以让我们动手改一下。
随机投篮和收集数据
我们可以通过将力度变为随机的来引入一些随机噪声。
float force = Random.Range(0f, 1f);
于是,我们终于可以看到成功得分的情况了(即使概率极低)。
Red投的很差,偶尔投进,但这是纯粹的运气。那没关系。此时,任何投篮都是我们可以使用的数据点。我们马上就会谈到这一点。
与此同时,我们不希望只能从一个地方投篮。我们希望Red能够从任何距离成功投篮。在Assets/BallSpawnController.cs中,查找这些行并取消MoveToRandomDistance()的注释。
yield return new WaitForSeconds(0.3f);
// MoveToRandomDistance();
运行后,我们会看到Red在每次投篮后都在球场上移动。
随机运动和随机力量的结合创造了一个非常奇妙的东西:数据。如果你查看Unity中的控制台,你会看到每次投篮时都会记录数据,成功的尝试会逐渐显现。
每次成功击球都会记录到目前为止成功进球的次数,距离篮筐的距离以及投篮所需的力量。这实在是太慢了,让我们为它加速。回到我们添加MoveToRandomDistance()的地方并将0.3f(每次投篮的延迟300毫秒)更改为0.05f(延迟50毫秒)。
yield return new WaitForSeconds(0.05f);
MoveToRandomDistance();
现在运行,看看这一次的投篮。
现在这是一个很好的训练制度!我们可以看到我们成功的投篮得分约6.4%。但他还不是库里。说到训练,我们真的从中学到了什么吗?TensorFlow呢?为什么这很有趣?这是我们下一步要做的。我们现在准备将这些数据从Unity中提取出来,并构建一个模型来预测所需的力度。
预测,模型和回归
在Google表格中查看我们的数据
在我们深入了解TensorFlow之前,我想看看数据,所以我让Unity运行直到Red成功完成大约50次投篮。这时查看Unity项目的根目录,应该看到一个新文件successful_shots.csv。这是来自Unity的每次成功投篮的原始储存!我有Unity导出这个,以便我可以在电子表格中轻松分析它。
这个.csv文件只有三列index,distance和force。我在Google表格中导入了这个文件并创建了一个带有趋势线的散点图,这样我们就可以了解数据的分布情况。
哇!看那个。我的意思是,看看那个。哇... 我也不知道是什么意思。让我来分析一下我们所看到的。
该图显示了一系列的点,这些点,投篮力度是Y轴,投篮距离是X轴基于拍摄的距离。在这里,我们看到的是所需的力与投篮距离之间有非常明确的相关性(有一些随机的例外情况)。
实际上,你可以将其视为“TensorFlow擅长的东西”。
虽然这个例子很简单,但是TensorFlow的优点之一是,如果我们愿意,我们可以使用类似的代码构建一个更复杂的模型。例如,在一个完整的游戏中,我们可以加入别的特征 - 比如其他游戏的位置,以及统计他们过去被盖帽的频率,以确定我们的球员是应该投篮还是传球。
创建我们的模型
在编辑器中打开tsjs/index.js。这个文件与Unity无关,只是一个基于数据(successful_shots.csv)训练模型的脚本。
下面是训练和保存模型的整个方法......
(async () => {
/*
Load our csv file and get it into a properly shaped array of pairs likes...
[
[distanceA, forceB],
[distanceB, forceB],
...
]
*/
var pairs = getPairsFromCSV();
console.log(pairs);
/*
Train the model using the data.
*/
var model = tf.sequential();
model.add(tf.layers.dense({units: 1, inputShape: [1]}));
model.compile({loss: 'meanSquaredError', optimizer: 'sgd'});
const xs = tf.tensor1d(pairs.map((p) => p[0] / 100));
const ys = tf.tensor1d(pairs.map((p) => p[1]));
console.log(`Training ${pairs.length}...`);
await model.fit(xs, ys, {epochs: 100});
await model.save("file://../Assets/shots_model");
})();
我们从.csv文件中加载数据 ,并创建一系列X和Y点。从那里我们要求模型“拟合”这些数据。之后,我们保存模型以备将来使用!
遗憾的是,TensorFlowSharp不接受Tensorflow.js可以保存的格式的模型。所以我们需要做一些翻译工作才能将我们的模型引入Unity。我已经嵌入了一些实用程序来帮助解决这个问题。一般过程是我们将我们的模型从TensorFlow.js Format转换为Keras Format,在哪里,我们可以创建一个检查点,与Protobuf Graph Definition合并得到Frozen Graph Definition,然后拉入Unity。
幸运的是,你可以跳过所有这些并且只运行tsjs/build.sh,如果一切顺利,它将自动完成所有步骤并在Unity中填充frozen模型。
在Unity内部,大家可以看一下Assets/BallSpawnController.cs中的GetForceFromTensorFlow(),看看模型互动的情况。
float GetForceFromTensorFlow(float distance)
{
var runner = session.GetRunner ();
runner.AddInput (
graph["shots_input"][0],
new float[1,1]{{distance}}
);
runner.Fetch (graph ["shots/BiasAdd"] [0]);
float[,] recurrent_tensor = runner.Run () [0].GetValue () as float[,];
var force = recurrent_tensor[0, 0] / 10;
Debug.Log(String.Format("{0}, {1}", distance, force));
return force;
}
在进行图的定义时,你将定义一个具有多个步骤的复杂系统。在我们的例子中,我们将模型定义为单个稠密层(具有隐式输入层),这意味着我们的模型采用单个输入并为我们提供一些输出。
在TensorFlow.js中使用model.predict时,它会自动将输入提供给正确的输入图节点,并在计算完成后为你提供正确节点的输出。但是,TensorFlowSharp的工作方式不同,需要我们通过名称直接与图节点进行交互。
考虑到这一点,需要将输入数据转换为图所需的格式并将输出发送给Red。
比赛时间
使用上面的系统,我在模型上创建了一些变体。这是使用仅仅500次成功投篮训练的模型,Red的投篮如下。
我们看到进球率增加了近10倍!如果我们训练Red几个小时并收集10k或100k成功率会怎么样?好吧,我会把它留给你实现。
GitHub:https://github.com/abehaskins/tf-jam
更多TensorFlow系统模型点击“这里”下载