Script to calculate the position and rotation of a semi trailer based on the path of it's tractor

Hi, I have been wrangling this problem for several days, and with several different approaches. The latest approach I am using seems the most promising, but I am having some issues with it I hope to sort out. It’s based on an approach I found in a whitepaper here. The pertinent section, 3.4.1, would be a formatting nightmare to post here, so I copied the section as images.



So, I wrote a script which I believe follows these equations. It uses a spline to describe the path of the tractor. Here is the script:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Splines;

public class TrailerMovement : MonoBehaviour
{
    public float d1 = 6.2f;
    public float d2 = .6f;
    public float d3 = 33.4f;
    public float deltaTime = 0.5f;

    private float time = 0f;
    private float angleB = 0f;
    private float angleD = 0f;

    public SplineContainer testSplineContainer;
    GameObject visualizationObjectParent;

    public float simulationDuration = 10f;

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            StartCoroutine(runTrailerSim());
        }
    }

    private IEnumerator runTrailerSim()
    {
        for (float i = deltaTime; i <= simulationDuration; i+=deltaTime)
        {
            visualizationObjectParent = new GameObject();
            createVisual();
            UpdateTrailerPosition();
            yield return null;
            //yield return new WaitForSeconds(deltaTime);
        }
    }
    void UpdateTrailerPosition()
    {
        // Calculate position and direction of the rear-wheel center point B
        Vector3 positionB = GetPositionB(time);
        Vector3 directionB = GetDirectionB(time);

        // Calculate position of the front wheel A
        Vector3 positionA = positionB + (d1 + d2) * new Vector3(Mathf.Sin(angleB), 0f, Mathf.Cos(angleB));

        // Calculate angle between tractor and trailer direction
        float anglePsi = angleD - angleB;

        // Calculate angular velocity at the center point D of the trailer
        float angularVelocityD = Mathf.Abs(GetVelocityB(time)) / d3 * Mathf.Sin(anglePsi) + Mathf.Abs(GetAngularVelocityB(time)) * d2 / d3 * Mathf.Cos(anglePsi);


        // Update angles and time
        angleD -= angularVelocityD * deltaTime;
        angleB = GetDirectionB(time + deltaTime).x; // Assuming x-axis represents the direction

        // Calculate position of the trailer wheel's rear center point D
        Vector3 positionD = positionB + d2 * new Vector3(Mathf.Sin(angleB), 0f, Mathf.Cos(angleB)) + d3 * new Vector3(-Mathf.Sin(angleD), 0f, -Mathf.Cos(angleD));
        // Update time
        time += deltaTime;

        // Use the calculated position and rotation for your trailer
        visualizationObjectParent.transform.rotation = Quaternion.Euler(0f, angleD * Mathf.Rad2Deg, 0f);
        visualizationObjectParent.transform.position = positionD;

        //transform.position = positionD;
        //transform.rotation = Quaternion.Euler(0f, angleD * Mathf.Rad2Deg, 0f); // Assuming rotation is around the y-axis

    }

    Vector3 GetPositionB(float time)
    {
        Vector3 position = testSplineContainer.Spline.EvaluatePosition(time / simulationDuration);
        visualizationObjectParent.transform.position = position;
        return position;
    }

    Vector3 GetDirectionB(float time)
    {
        Vector3 tangent = testSplineContainer.Spline.EvaluateTangent(time / simulationDuration);
        tangent.Normalize();
        return tangent;

    }

    float GetVelocityB(float time)
    {
        Vector3 velocity = (GetPositionB(time) - GetPositionB(time - deltaTime)) / deltaTime;
        return velocity.magnitude;
    }

    float GetAngularVelocityB(float time)
    {
        float directionAtTime = CalculateDirectionB(time);
        float directionAfter = CalculateDirectionB(time + deltaTime);
        float angularVelocity = (directionAfter - directionAtTime) / deltaTime;
        return angularVelocity;
    }

    float CalculateDirectionB(float time)
    {
        Vector3 tangent = testSplineContainer.Spline.EvaluateTangent(time / simulationDuration);
        float direction = Mathf.Atan2(tangent.z, tangent.x);
        if(direction < 0)
        {
            direction += 2 * Mathf.PI;
        }
        return direction;
    }

    private void createVisual()
    {
        GameObject test = GameObject.CreatePrimitive(PrimitiveType.Cube);
        test.transform.localScale = new Vector3(.5f, .5f, 36);
        test.transform.parent = visualizationObjectParent.transform;
        test.transform.localPosition = new Vector3(0, 0, 18);
        test.GetComponent<Renderer>().material.color = Color.red;
    }
}

When I run the script on a simple spline, I get a result that looks pretty good - it is what I would expect: (the red lines represent the trailer at discrete intervals)

However, when I apply this script to a spline which curves back more, it breaks:

I’m not very strong with this sort of geometry and I have not had much luck with my trial an error approach to figuring it out over the last couple of days, so I’m hoping someone can help me understand what’s going on.

Thanks!

I think your error is this

angleB = GetDirectionB(time + deltaTime).x; // Assuming x-axis represents the direction

This makes no sense, why would you take only the x component? This is 2D vector, by taking only the x component, you’re destroying it. The whole thing is a direction. Just remove that .x part

I would also write many of these lines differently.
For example (start by removing the boilerplate)

static Vector2 tov2(Vector3 v) => new Vector2(v.x, v.z);
static Vector3 tov3(Vector2 v) => new Vector3(v.x, 0f, v.y);

static float sin(float rad) => MathF.Sin(rad); // System namespace; don't use Mathf for trigonometry
static float cos(float rad) => MathF.Cos(rad);
static Vector2 sincos(float rad) => new Vector2(sin(rad), cos(rad));

static float angle(Vector2 v) {
  var theta = MathF.Atan2(v.y, v.x);
  if(theta < 0f) theta += 2f * MathF.PI;
  return theta;
}

static float abs(float n) => Math.Abs(n);
static Quaternion roty(float rad) => Quaternion.AngleAxis(rad * Mathf.Rad2Deg, Vector3.up);
//static Quaternion roty(float rad) => Quaternion.Euler(0f, rad * Mathf.Rad2Deg, 0f); // ^^ much faster

Vector2 samplePos(float time)
  => tov2(testSplineContainer.Spline.EvaluatePosition(time / _simDuration));

Vector2 sampleTangent(float time)
  => tov2(testSplineContainer.Spline.EvaluateTangent(time / _simDuration)).normalized;
void UpdateTrailerPosition() {

  var dt = deltaTime;

  var posb = samplePos(_time);
  //var dirb = sampleTangent(_time); // you never use this

  var posa = posb + (d1 + d2) * sincos(_angb); // I'm assuming this is tractor pos? you never use this either

  var psi = _angd - _angb;

  var angveld = ( abs(calcVel()) * sin(psi) + d2 * abs(calcAngVel()) * cos(psi) ) / d3;

  _angd -= angveld * dt;
  _angb = angle(sampleTangent(_time + dt));

  var posd = posb + d2 * sincos(_angb) + d3 * -sincos(_angd);
  _time += dt;

  visualizationObjectParent.transform.rotation = roty(_angd);
  visualizationObjectParent.transform.position = tov3(posd);

  float calcVel() => (samplePos(_time) - samplePos(_time - dt)).magnitude / dt;
  float calcAngVel() => (angle(sampleTangent(_time + dt)) - angle(sampleTangent(_time))) / dt;

}

Maybe it’s just me, but I can negotiate with this better.
Once the dust settles down, you can rename / flesh out the var names for longer shelf life.

Edit: some fixes
Edit2: sampleTangent doesn’t need to return a normalized vector, because in this particular situation, you only ever compute angles with it; atan2 is perfectly tolerant to non-unit vectors (it works with y/x ratio internally, so it doesn’t matter). fyi: normalized and magnitude are more expensive than they look.
Edit3: sample functions cannot be static; also fixed calcVel and calcAngVel being called wrongly.

Also this was confusing

float CalculateDirectionB(float time)
{
  Vector3 tangent = testSplineContainer.Spline.EvaluateTangent(time / simulationDuration);
  float direction = Mathf.Atan2(tangent.z, tangent.x);
  if(direction < 0)
  {
    direction += 2 * Mathf.PI;
  }
  return direction;
}

This is not a direction, this is an angle. And then you’re using it to extrapolate angular velocity.
Directions are unit vectors. I’ve renamed this to angle above.

Also the consistent ordering of (sin, cos) in the equations is very peculiar. Usually this kind of geometry in Unity is (cos, sin) and a perpendicular situation would be (-sin, cos) or (sin, -cos). So I think they’re measuring their angles from the top, instead of from the right (which is what trig functions do). And that would be my next suspect.

Here’s a shot of the actual equations in the paper, for posterity

9063391--1253395--upload_2023-6-7_14-52-16.png

2 Likes

Also observe the asymmetry between lines 23 and 24 in my version.

float calcVel() => (samplePos(_time) - samplePos(_time - dt)).magnitude / dt;
float calcAngVel() => (angle(sampleTangent(_time + dt)) - angle(sampleTangent(_time))) / dt;

These two function should sample the same time stamps, currently one is reaching to the past, and the other to the future. This is completely opaque in your coding style.

I will try to untangle this in my spare time. This needs to be rewritten with more attention to details.

Hopefully you’ll leave a comment in the meantime.

Thank you so much for your attention on this Orion. I started to get into your suggestions last night and I did a re-write based on the code you provided, but I’m a bit unfamiliar with the way you are writing things and it’s taking me a bit to understand what you have suggested. You’re right that my script is a bit of a mess - I was essentially just randomly trying different things to try and figure out what was going on, so it definitely needs a cleanup and a more consistent approach at this point. I’m going to be working on it today and I will update with my progress.

Try my approach, not because it’s mine, but because it’s much cleaner to work with.
I’ve developed this style after working with shaders a lot. This also naturally translates to Unity.Mathematics.

You can easily discern between the symbols, acknowledge patterns and move things around / experiment.
Bundle reusable techniques in their own functions, remove all boilerplate from sight, and develop your own static lexicon. Contrary to C# standards, the shorter the names the better; as I said, you can easily rename them for longevity when you’re finished.

If I find some time, I will definitely try to make this. But I’m in a midst of building something else entirely, and I can’t tell when that’ll be.

Well I did nothing particularly extraordinary. If you have any concrete questions, feel free to ask.

C# wise, the only features I’m using here are

Geometry-wise, this is heavy on

Yeah, the expression-body definitions and local functions were something I have not used before; I can see how they improve readability and reduce the chance of unintended things happening, respectively. I also really need to improve my understanding of trigonometry to be able to better identify what is actually happening. I am wondering why you made most of the member functions static?

Because they’re pure, live outside of instance context, and are extremely likely to be inlined by compiler, when paired with an expression-body. Also traditionally, math libraries are made to be static, because these just parachute in (Math, MathF, Mathf).

I would argue that any method that doesn’t need immediate access to instance data should be static, but there are no hard rules about this, and certainly there could be some design constraints which is why there are no hard rules about this.

3 Likes

Also btw, historically C# didn’t have a dedicated 32-bit precision math library, only a 64-bit one. This is why there is this confusion right now between Mathf and MathF. MathF has been added to the language only recently, and prior to that, the only convenient way to work with float math was Mathf made by Unity.

However, making low-level math libraries is a huge problem, and so while most of it is ok and tied up to Unity workflows, when it comes to trigonometry, logarithms, square roots et al, all Unity does there is casting your float to double, calling a corresponding function in System.Math, then casting the result back to float. Not very good for performance, right?

In recent times, we now have a dedicated library from Unity (Unity.Mathematics), as well as System.MathF. The performance boost can be huge.

Also, regarding local functions, the same thing applies with them: if they’re small and expression-bodied they are extremely likely to become inlined, which is what you want most of the time. Otherwise, the sacrifice would be too great in most applications. Apart from letting you reuse some code without cluttering your class member space, they also come in two flavors, static and non-static, and the difference is wild.

Static local functions behave “normally” in a sense that they are treated as separate functions with nothing funky going on, but non-static local functions have this weird but useful feature that’s introduced with lambdas: variable capturing.

In other words, they can hook onto variables of their parent scope by reference, making them even more useful (but sometimes horrible if you don’t know what you’re doing). And you can always opt-out by making them static, which is pretty neat.

I honestly can’t imagine going back to times without them. I’ve just pondered over some of my workhorse functions in the project I’m working on, and tried to imagine how different they would look and operate without local functions. The conclusion was “a lot”, though the change is not always obviously for the absolute better, they tend to be more readable, and offer a different, let’s say function-oriented mind set.

Yeah, I see there is some stuff there that I have been completely oblivious to. Thanks for opening my eyes to it, definitely going to start working with it

1 Like

Is it your interpretation that the red line represents the spline?

No. I haven’t really made any assumptions on how you coded the equations, except that .x thing which I removed. All I did was transform your code into something more workable. Spline is not shown in that picture, btw. The tractor (A) should be on the spline, as a point, and Va should be the spline’s tangent. I’m not sure about B, I would have to look at that more closely, but D is definitely not on the spline.

Edit: See the next post for a more accurate answer.

I’ve found this confusing already, why they find Pa, only to then do nothing with it. You didn’t do anything either.
After reading this, here’s an explanation, this entire model is based on the rear wheels of the tractor.

(then goes the above picture)

So the idea is to sample B from the spline, then compute A out of that, which is how it looked like. Now you can obtain C, and D from C and the angles involved.

So the answer is: Only the point B is exactly on the spline, though point A quite possibly tries to get onto it as well. That’s the key constraint they’re using.

In any case, you most definitely want to be able to understand this geometrically and apply trigonometry on your own to solve this independently.

This is all pretty basic math, and it’s very nice that you get all the equations so you get a point of comparison.

Right. I guess Pa can be used to position the tractor for animation purposes, but it’s not useful in the determination of the trailer. As you just noted, the idea is to sample B from the spline, so I assume the spline would be something like this:
9064192--1253638--trailer diagram.jpeg
I just noticed the axis designation in the lower left corner of that diagram, so does the red line represent the y axis?

Yes, that’s roughly where spline is going through.

Yes.

Yes haha, I’ve noticed that already in post #4 . It’s just silly. But that explains perfectly that (sin, cos) situation. They just align their 0° to the North, instead to the East.

When you rotate the whole image by 90° clockwise you get (cos, -sin). Anyway now you can see the advantage of having split methods for these things. You can do

static float sin(float rad) => MathF.Cos(rad);
static float cos(float rad) => -MathF.Sin(rad);

and blatantly pretend that everything’s ok. You can easily fix everything up once you have math that works.

I’d still double-check everything by hand. Such twists are notoriously hard to work with because this is pretty much a fundamental swap.

Anyway at this point it makes sense to try and work this out backwards.

You want to find D, and to find it you need to know C, psi, and d3. At least in my interpretation.
C is easy if you have A, B, d1, and d2. It’s just linear interpolation between the two.
But they skip C altogether, I’m not sure why. Maybe it’s left as an exercise for the reader?
You definitely need it but they somehow get to D directly from B in equation (5).
Quite possibly C is hidden here in the second term of that equation and the third term is the actual semi-trailer hanging out in the back.

Then they claim psi = angb - angd and if I’m correct these are the angles of Vb and Vd against the Y axis (which is the upper horizontal line). There is some geometric principle here, so you really want to compute these angles more respectfully, and make them signed, not unsigned like you did.

What else we need? Well you need B, which is sampled according to time.

Then they compute angd and D for the next time step. They are using angular velocity of D to find out how much angd should change. That part is actually very clever.

Throughout that equation (5) they’re referring to angles in the future. So you need to take the sample of angb in the future, and you already have future angd from equation (4).