The Full Series
- Part 1: We create the whole
NeuralNetwork
class from scratch. - Part 2: We create an environment in Unity in order to test the neural network within that environment.
- Part 3: We make a great improvement to the neural network already created by adding a new type of mutation to the code.
Introduction
A few days ago, I posted this article explaining how you can implement a neural network from scratch in C#. However, in the last article, the neural network was trained on an XOR function. As promised, we're going to train simple cars, in Unity, to drive! Here's what we're aiming for:
After I finished the video, I felt like it was some creepy 90s clip, but it does the job ...
Background
To follow along this article, you'll need to have basic C# and Unity programming knowledge. Also, you're going to need to have to read my previous article where I first implemented the NeuralNetwork
class.
Pre-Programming Resources
In case you're new to C#, you can always search the MSDN docs for the stuff you're not familiar with, but in case you want to look for something Unity-specific, you may want to search Unity's Scripting Reference or Unity's Manual instead.
Using the Code
First off, you gotta know all the classes that are going to be used in the project:
Car
: The main script that controls the movement of the car
object (controlled by a NeuralNetwork
or by the user). Wall
: A simple script that is attached to every wall. It sends a "Die
" message to a car
if it hits an object with this script on it. Checkpoint
: A simple script that increases the fitness(score) of a car
once it is hit. EvolutionManager
: That script simply waits for all the car
s to die, then it makes a new generation from the best car
. CameraFollow
: That's the function that changes to position of the camera
to look at the best car
.
Here's how it's all going to work:
- There is going to be a track with a series of checkpoints along its path.
- Once a
car
hits a checkpoint, its fitness increases. - If a
car
hits a wall, it gets destroyed. - If all the
car
s are destroyed, a new generation is created from the best car
in the last generation.
Now, we're going to go over each script and explain it in a little more detail.
NeuralNetwork
An entire article was devoted to that one ...
Car
First, we got to have a few variables defined:
[SerializeField] bool UseUserInput = false;
[SerializeField] LayerMask SensorMask;
[SerializeField] float FitnessUnchangedDie = 5;
public static NeuralNetwork NextNetwork = new NeuralNetwork
(new uint[] { 6, 4, 3, 2 }, null);
public string TheGuid { get; private set; }
public int Fitness { get; private set; }
public NeuralNetwork TheNetwork { get; private set; }
Rigidbody TheRigidbody;
LineRenderer TheLineRenderer;
That's what we should do whenever a new car
is created:
private void Awake()
{
TheGuid = Guid.NewGuid().ToString();
TheNetwork = NextNetwork;
NextNetwork = new NeuralNetwork(NextNetwork.Topology, null);
TheRigidbody = GetComponent<Rigidbody>();
TheLineRenderer = GetComponent<LineRenderer>();
StartCoroutine(IsNotImproving());
TheLineRenderer.positionCount = 17;
}
This is the IsNotImproving
function:
IEnumerator IsNotImproving ()
{
while(true)
{
int OldFitness = Fitness;
yield return new WaitForSeconds(FitnessUnchangedDie);
if (OldFitness == Fitness)
WallHit();
}
}
This is the Move
function that(wait for it...) "Moves" the car:
public void Move (float v, float h)
{
TheRigidbody.velocity = transform.right * v * 4;
TheRigidbody.angularVelocity = transform.up * h * 3;
}
Then comes the CastRay
function that does a casts and visualises rays. It'll be used later on:
double CastRay (Vector3 RayDirection, Vector3 LineDirection, int LinePositionIndex)
{
float Length = 4;
RaycastHit Hit;
if (Physics.Raycast(transform.position, RayDirection,
out Hit, Length, SensorMask))
{
float Dist = Vector3.Distance
(Hit.point, transform.position);
TheLineRenderer.SetPosition(LinePositionIndex,
Dist * LineDirection);
return Dist;
}
else
{
TheLineRenderer.SetPosition(LinePositionIndex,
LineDirection * Length);
return Length;
}
}
Follows ... the GetNeuralInputAxisFunction
that does a lot of the work for us:
void GetNeuralInputAxis (out float Vertical, out float Horizontal)
{
double[] NeuralInput = new double[NextNetwork.Topology[0]];
NeuralInput[0] = CastRay(transform.forward, Vector3.forward, 1) / 4;
NeuralInput[1] = CastRay(-transform.forward, -Vector3.forward, 3) / 4;
NeuralInput[2] = CastRay(transform.right, Vector3.right, 5) / 4;
NeuralInput[3] = CastRay(-transform.right, -Vector3.right, 7) / 4;
float SqrtHalf = Mathf.Sqrt(0.5f);
NeuralInput[4] = CastRay(transform.right * SqrtHalf +
transform.forward * SqrtHalf, Vector3.right * SqrtHalf +
Vector3.forward * SqrtHalf, 9) / 4;
NeuralInput[5] = CastRay(transform.right * SqrtHalf + -transform.forward * SqrtHalf,
Vector3.right * SqrtHalf + -Vector3.forward * SqrtHalf, 13) / 4;
double[] NeuralOutput = TheNetwork.FeedForward(NeuralInput);
if (NeuralOutput[0] <= 0.25f)
Vertical = -1;
else if (NeuralOutput[0] >= 0.75f)
Vertical = 1;
else
Vertical = 0;
if (NeuralOutput[1] <= 0.25f)
Horizontal = -1;
else if (NeuralOutput[1] >= 0.75f)
Horizontal = 1;
else
Horizontal = 0;
if (Vertical == 0 && Horizontal == 0)
Vertical = 1;
}
And, that's what we do 50 times per second:
private void FixedUpdate()
{
if (UseUserInput)
Move(Input.GetAxisRaw("Vertical"),
Input.GetAxisRaw("Horizontal"));
else
{
float Vertical;
float Horizontal;
GetNeuralInputAxis(out Vertical, out Horizontal);
Move(Vertical, Horizontal);
}
}
We also need to have a few functions that're going to be called from other scripts (Checkpoint
and Wall
):
public void CheckpointHit ()
{
Fitness++;
}
public void WallHit()
{
EvolutionManager.Singleton.CarDead(this, Fitness);
gameObject.SetActive(false);
}
Wall
The Wall
script simply notifies any car that hits it:
using UnityEngine;
public class Wall : MonoBehaviour
{
[SerializeField] string LayerHitName = "CarCollider";
private void OnCollisionEnter(Collision collision)
{
if (collision.gameObject.layer == LayerMask.NameToLayer(LayerHitName))
{
collision.transform.GetComponent<Car>().WallHit();
}
}
}
Checkpoint
The Checkpoint
does almost the same thing as the Wall
, but with a twist. Checkpoint
s use a Trigger
instead of a Collider
, and Checkpoint
s also make sure they increase the fitness of each car
only once. This is why each Car
has a Unique ID. Each Checkpoint
simply saves all the Guid
s of Car
s increased before:
using System.Collections.Generic;
using UnityEngine;
public class Checkpoint : MonoBehaviour
{
[SerializeField] string LayerHitName = "CarCollider";
List<string> AllGuids = new List<string>();
private void OnTriggerEnter(Collider other)
{
if(other.gameObject.layer == LayerMask.NameToLayer(LayerHitName))
{
Car CarComponent = other.transform.parent.GetComponent<Car>();
string CarGuid = CarComponent.TheGuid;
if (!AllGuids.Contains(CarGuid))
{
AllGuids.Add(CarGuid);
CarComponent.CheckpointHit();
}
}
}
}
EvolutionManager
You can't write a script without variables:
public static EvolutionManager Singleton = null;
[SerializeField] int CarCount = 100;
[SerializeField] GameObject CarPrefab;
[SerializeField] Text GenerationNumberText;
int GenerationCount = 0;
List<Car> Cars = new List<Car>();
NeuralNetwork BestNeuralNetwork = null;
int BestFitness = -1;
On the start of the program:
private void Start()
{
if (Singleton == null)
Singleton = this;
else
gameObject.SetActive(false);
BestNeuralNetwork = new NeuralNetwork(Car.NextNetwork);
StartGeneration();
}
That's how a new generation is created:
void StartGeneration ()
{
GenerationCount++;
GenerationNumberText.text = "Generation: " + GenerationCount;
for (int i = 0; i < CarCount; i++)
{
if (i == 0)
Car.NextNetwork = BestNeuralNetwork;
else
{
Car.NextNetwork = new NeuralNetwork(BestNeuralNetwork);
Car.NextNetwork.Mutate();
}
Cars.Add(Instantiate(CarPrefab, transform.position,
Quaternion.identity, transform).GetComponent<Car>());
}
}
Stuff called by the Car
s:
public void CarDead (Car DeadCar, int Fitness)
{
Cars.Remove(DeadCar);
Destroy(DeadCar.gameObject);
if (Fitness > BestFitness)
{
BestNeuralNetwork = DeadCar.TheNetwork;
BestFitness = Fitness;
}
if (Cars.Count <= 0)
StartGeneration();
}
CameraFollow
Just another simple all-in-one script that does the job:
using UnityEngine;
public class CameraFollow : MonoBehaviour
{
Vector3 SmoothPosVelocity;
Vector3 SmoothRotVelocity;
void FixedUpdate ()
{
Car BestCar = transform.GetChild(0).GetComponent<Car>();
for (int i = 1; i < transform.childCount; i++)
{
Car CurrentCar = transform.GetChild(i).GetComponent<Car>();
if (CurrentCar.Fitness > BestCar.Fitness)
{
BestCar = CurrentCar;
}
}
Transform BestCarCamPos = BestCar.transform.GetChild(0);
Camera.main.transform.position = Vector3.SmoothDamp
(Camera.main.transform.position, BestCarCamPos.position,
ref SmoothPosVelocity, 0.7f);
Camera.main.transform.rotation = Quaternion.Lerp(Camera.main.transform.rotation,
Quaternion.LookRotation(BestCar.transform.position -
Camera.main.transform.position),
0.1f);
}
}
Points of Interest
Now that we have all the scripts explained in detail, you can sleep well knowing that the NeuralNetwork
class previously implemented works well and is not a waste of time. It felt really good to see those cars learning how they can drive through the track so step-by-step. Also, the car uses built-in sensors, which means that the car can drive on tracks it didn't learn driving on before. Once I got that done, I felt like my binary children were learning how to drive! I tried as hard as I can to make this implementation as simple as possible for people who don't wanna deeply dig into Unity's stuff. And... never think for a bit that we're done here. My current target is to implement 3 Crossover operators to make evolution a bit more efficient and offer the developer more diversity. After that, Backpropagation is the target.
Update on 20th February, 2018
Part 3 is up and running! It shows a substantial improvement over the system discussed in Parts 1 and 2. Tell me what you think!
History
- 11th December 2017: Version 1.0: Main Implementation