Creating Trajectory Prediction Using Physics Simulations in Unity

Simon Pham
7 min readJul 24, 2024

--

Why do we need Trajectory Prediction?

Remnant 2

In video games, trajectory prediction is commonly used to calculate and forecast the path that an object will follow based on its current state and the forces acting upon it. This is particularly useful for scenarios such as when a character uses a weapon that shoots projectiles in an arc trajectory, like a grenade launcher or a ball that bounces off surfaces.

There are several methods to create a trajectory system, depending on your end goal and preferences, and in today’s blog post, we will be using physics to do so.

Setting up our scene

Setting up our scene: In the scene, we have a launcher and a small lab room with a glass door. Our objective is to launch a package through the window of the glass door to the portal.

Making the launcher fire off packages

Package prefab

Since I want to instantiate a package each time the player presses the space key, I’ll turn it into a prefab with a Box Collider and a Rigidbody component.

Package

Next, I’ll create a script called AirmailPackage and attach it to the package prefab. In the script, we’ll create a variable for the Rigidbody component and a public method called Init that the launcher can use to apply a force to the package.

public class AirmailPackage : MonoBehaviour
{
[SerializeField] private Rigidbody _rb;

public void Init(Vector3 velocity)
{
_rb.AddForce(velocity, ForceMode.Impulse);
}
}

Don’t forget to assign the Rigidbody component in the Inspector.

Launcher

For the Launcher script, we first need to create some variables for the package prefab and the launch force. I also want to have an empty GameObject that acts as a container for the instantiated packages so we can keep the Hierarchy window organized.

[SerializeField] private AirmailPackage _airmailPackagePrefab;
[SerializeField] private float _force;
[SerializeField] private GameObject _container;

Next, let’s create a function called LaunchPackages to fire off packages toward the portal when the player press the space key.

private void Update()
{
LaunchPackages();
}

private void LaunchPackages()
{
if (Input.GetKeyDown(KeyCode.Space))
{
var spawned = Instantiate(_airmailPackagePrefab, transform.position, transform.rotation);
spawned.transform.parent = _container.transform;
spawned.Init(transform.forward * _force);
}
}

Before we test our game, let’s quickly assign the variables in the Inspector. I’m gonna drag and drop the package prefab and the container into their corresponding fields and input 10 for the Force.

And here’s the result:

Our launcher is now working as expected, but we failed to launch the packages through the window. Now, let’s create a trajectory line to assist our aiming.

Creating a trajectory line by using a simulated physics scene

How does this method work?

As mentioned above, there are several solutions to this issue, but in this article, we’ll focus on using simulated physics scenes. The method involves creating a new, separate scene from the current one to simulate the process of firing projectiles every single frame. This will provide us with the positions of our projectile along the arc, which we can then use in a Line Renderer component to visualize the trajectory line.

Creating a simulated physics scene

First, I’m going to create a new C# script called SimulatedPhysics and attach it to the launcher. Next, let’s create references to the new simulated scene, its physics scene, and the lab room (which is the topmost parent of the launcher).

private Scene _simulatedScene;
private PhysicsScene _physicsScence;
private Transform _labParent;

void Start()
{
_labParent = gameObject.transform.parent.root;
}

Next, I’m gonna create a new method called CreateSimulatedPhysicsScene.

void CreateSimulatedPhysicsScene()
{
_simulatedScene = SceneManager.CreateScene("Simulated Physics", new CreateSceneParameters(LocalPhysicsMode.Physics3D));
_physicsScence = _simulatedScene.GetPhysicsScene();
}

In this method, we use the CreateScene method to create a new scene called Simulated Physics, which uses the Physics3D mode, and then we obtain the physics scene from it using the GetPhysicsScene method.

Note 1: Ensure to include the UnityEngine.SceneManagement namespace at the top of the script.

Note 2: You can check out the documentation for the SceneManagement class here.

Next, I’m gonna call this method in the Start method, and here’s the full code:

using UnityEngine;
using UnityEngine.SceneManagement;


public class SimulatedPhysics : MonoBehaviour
{
private Scene _simulatedScene;
private PhysicsScene _physicsScence;
private Transform _labParent;

void Start()
{
_labParent = gameObject.transform.parent.root;
CreateSimulatedPhysicsScene();
}

void CreateSimulatedPhysicsScene()
{
_simulatedScene = SceneManager.CreateScene("Simulated Physics", new CreateSceneParameters(LocalPhysicsMode.Physics3D));
_physicsScence = _simulatedScene.GetPhysicsScene();
}
}

When I hit the Play button, I could see that a new empty scene was created in the Hierarchy window.

Moving objects to the new scene

Now that we have successfully created a new scene, let’s move some of the objects to the new scene. For this case, I’m going to move the glass wall, the portal, and the platforms that it’s placed on. To ensure the package can collide with the lab walls, we’ll move them as well. Instead of moving their actual meshes, I’ll create an object called Physics Walls that contains all the Box Collider components of the walls and move it to the new scene, which will be more performant.

To easily select these objects, I’m gonna tag them as Obstacle. In the SimulatedPhysics script, let’s create a new method called MoveObjectsToNewScene.

void MoveObjectsToNewScene()
{
foreach (Transform gameObject in _labParent)
{
if (gameObject.CompareTag("Obstacle"))
{
var simulatedObject = Instantiate(gameObject.gameObject, gameObject.position, gameObject.rotation);
if (simulatedObject.GetComponent<Renderer>() != null)
{
gameObject.GetComponent<Renderer>().enabled = false;
}

SceneManager.MoveGameObjectToScene(simulatedObject, _simulatedScene);
}
}
}

Got it, no quotes. Here’s the corrected version:

In this method, we select all children of the lab parent that were tagged as Obstacle, clone them using the Instantiate method, disable their mesh (because we don’t want to have two meshes for each GameObject in the scene), and move them to the new scene using the MoveGameObjectToScene method.

In Play mode, we can see the selected objects recreated in the new scene.

Creating the trajectory line using line renderer

First, I’m going to create a public method called CreateSimulatedTrajectory in the SimulatedPhysics script.

public void CreateSimulatedTrajectory(AirmailPackage airmailPackagePrefab, Vector3 pos, Vector3 velocity)
{
var simulatedObj = Instantiate(airmailPackagePrefab, pos, Quaternion.identity);
if (simulatedObj.GetComponent<Renderer>() != null)
{
simulatedObj.GetComponent<Renderer>().enabled = false;
}
SceneManager.MoveGameObjectToScene(simulatedObj.gameObject, _simulatedScene);
simulatedObj.Init(velocity);
Destroy(simulatedObj.gameObject);
}

In this method, we instantiate the package, disable its mesh, move it to the simulated scene, launch it forward using the Init method from the package script, and then destroy it.

Next, in the Launcher script, we get a reference to the SimulatedPhysics script, assign it in the Inspector, and call the CreateSimulatedTrajectory function in the FixedUpdate method.

[SerializeField] private SimulatedPhysics _simulatedPhysics;

private void FixedUpdate()
{
_simulatedPhysics.CreateSimulatedTrajectory(_airmailPackagePrefab, transform.position, transform.forward * _force);
}

After that, let’s add a Line Renderer component to the launcher, set the Size property to 0 (because we will set it through code), adjust the width to your preference, and assign a material (for example, a basic white material).

In the SimulatedPhysics script, create references to the Line Renderer and the maximum physics iterations (which corresponds to the Size property of the Line Renderer component).

[SerializeField] private LineRenderer _line;
[SerializeField] private int _maxPhysicsIterations;

Let’s also assign the Line Renderer and the physics iterations in the Inspector.

Next, create a new method called DrawTrajectoryLine to visualize the trajectory line.

void DrawTrajectoryLine(AirmailPackage simulatedObject)
{
_line.positionCount = _maxPhysicsInterations;
for (int i = 0; i < _maxPhysicsInterations; i++)
{
_physicsScence.Simulate(Time.fixedDeltaTime);
_line.SetPosition(i, simulatedObject.transform.position);
}
}

Now, we can call this method in the CreateSimulatedTrajectory method, and pass in the simulated object.

public void CreateSimulatedTrajectory(AirmailPackage airmailPackagePrefab, Vector3 pos, Vector3 velocity)
{
var simulatedObj = Instantiate(airmailPackagePrefab, pos, Quaternion.identity);
if (simulatedObj.GetComponent<Renderer>() != null)
{
simulatedObj.GetComponent<Renderer>().enabled = false;
}
SceneManager.MoveGameObjectToScene(simulatedObj.gameObject, _simulatedScene);
simulatedObj.Init(velocity);

//We call this method before destorying the package
DrawTrajectoryLine(simulatedObj);

Destroy(simulatedObj.gameObject);
}

And here’s the result:

As you can see, the trajectory line seems a little too short. Instead of increasing the maximum physics iterations (which could reduce performance), we can increase the spacing between them. To achieve this, I’m going to adjust the time it takes to simulate each iteration in the DrawTrajectoryLine method by multiplying it by an integer, which I’ll call steps.

[SerializeField] private int _steps;

void DrawTrajectoryLine(AirmailPackage simulatedObject)
{
_line.positionCount = _maxPhysicsIterations;
for (int i = 0; i < _maxPhysicsIterations; i++)
{
_physicsScence.Simulate(Time.fixedDeltaTime * _steps);
_line.SetPosition(i, simulatedObject.transform.position);
}
}

I’ll set it to 4 to begin with.

Here’s the result:

This looks better. I’m going to increase the force to 11 and adjust the rotation of the launcher a little bit.

And here’s the final result:

--

--

No responses yet