Buy me a coffee on ko-fi.com
Reese Schultz

I'm making my first commercial game. Read more about me here 👋.

Projectile Motion with Unity DOTS

Learn how to achieve projectile motion with Unity DOTS.

unitycsharptutorial

Created on November 5, 2019. Last updated on January 19, 2020.

Video of projectile motion demonstration with Unity DOTS.

Get the tutorial code from GitHub! 👀 And to get the "projectiles" to launch to random positions, consider reading my Random Number Generation with Unity DOTS tutorial after you finish this one.

Note: If at any point you find this tutorial to be a little too advanced, don't worry! Try my Getting Started with Unity DOTS tutorial first.

To implement projectile motion with DOTS, the first thing we'll do is create a Projectile component. Its Target will just be the float3 position where the projectile ought to land (or, rather, where we try to make it land). When we set the Target, HasTarget will be set automatically set as well, which will modify with a setter. Note that we'll have a lowercase target that is de facto private, but not technically private, exposed by the getter and setter of Target. We will also have Gravity, which is just some arbitrary floating point number.

struct Projectile : IComponentData
{
    public bool HasTarget;
    public float AngleInDegrees;
    public float FlightDurationInSeconds;
    public float Gravity;
    public float3 target;
    public float3 Target
    {
        get
        {
            return target;
        }
        set
        {
            this.HasTarget = true;
            target = value;
        }
    }
}

In this example, we're not using Unity.Physics for gravity⁠—we're just using our own made-up, artificial gravity. AngleInDegrees represents the initial launch angle. The last field to mention is FlightDurationInSeconds, which tracks how long the projectile has been in flight. That covers all the component data we'll need.

Anyway, how about a Burst-compiled job to manipulate this data?

protected override JobHandle OnUpdate(JobHandle inputDeps)
{
    var deltaSeconds = Time.DeltaTime;

    return Entities
        .ForEach((ref Projectile projectile, ref Translation translation) =>
        {
            if (!projectile.HasTarget) return;

            var velocity = Vector3.Distance(translation.Value, projectile.Target) / (math.sin(2 * math.radians(projectile.AngleInDegrees)) / projectile.Gravity);
            var xVelocity = math.sqrt(velocity) * math.cos(math.radians(projectile.AngleInDegrees));
            var yVelocity = math.sqrt(velocity) * math.sin(math.radians(projectile.AngleInDegrees));

            translation.Value.y += (yVelocity - projectile.FlightDurationInSeconds * projectile.Gravity) * deltaSeconds;
            translation.Value = Vector3.MoveTowards(translation.Value, projectile.Target, xVelocity * deltaSeconds);

            projectile.FlightDurationInSeconds += deltaSeconds;

            if (translation.Value.y > projectile.Target.y) return;

            projectile.FlightDurationInSeconds = 0;
            projectile.HasTarget = false;
        })
        .WithName("LaunchJob")
        .Schedule(inputDeps);
}

Using the Entities.ForEach syntactic sugar, we process entities with both Projectile and (built-in) Translation components, both of which will be read from and written to, hence the use of ref. deltaSeconds originates from the main thread, which is passed into the job by mere use. Note that we exit the job ASAP if there is no target. Next we find the required velocity and its horizontal and vertical components based on the givens, those being the current position (translation.Value), Target, AngleInDegrees, and Gravity. For a refresher on the math, read the Wikipedia page on projectile motion.

The current position (translation.Value) is updated according to said math. Naively, because this is just for the sake of demonstration, we reset the target and flight duration if the actual vertical component is greater than or equal to the expected one. That's good enough for this example, but you can modify this to handle more complex surfaces, falling, etc.

It's up to you to create entities with appropriate Projectile and Translation components attached to them. My GitHub repo has all the additional glue code and parameters you'd need for that (please star it if you use it!). But if you don't use that as a reference, just remember to set the following variables on a fresh Projectile:

  • The initial position - translation.Value.
  • The launch angle - projectile.AngleInDegrees (try 45).
  • The artificial gravity - projectile.Gravity (try 100).
  • The target position - projectile.Target.

The other variables are only supposed to be managed by the code we already wrote, so please avoid messing with them. Or not, lol.