Workshop V - Start Your Engines (Mirror)


This post is a mirror of a message board post I wrote in 2021, reposted as-is. There are no errors I'm aware of, but it would be different if I wrote it all again. I'm copying it here for preservation and because it makes sense. ๐Ÿ™‚

Start Your Engines

For an engine simulation, we need a torque curve. I am using a common method as suggested by Marco Monster (Car Physics for Games) -- I keep a series of torque values (250RPM per step) in an array, and pull the torque value for the current RPM by interpolating between the two nearest entries in the array. The function to get the current torque value looks like this:

func torque():
    rpm = clamp(rpm, 0, rpm_limit)  # avoid array index error
    return lerp(torque_curve[ceil(rpm / 250) - 1], torque_curve[ceil(rpm / 250)], fmod(rpm, 250) / 250)

Now we can calculate the current torque value to apply to the wheels. Here's where it gets tricky. We have a torque value based on RPM that spins the wheels, but the RPM also depends upon how quickly the drivewheels are spinning. We need to allow the car to idle, a way to ease into 1st gear (ie. engaging a clutch), and a way of updating the RPM and drivewheel speed in tandem.

I found this tutorial for a drift simulator in Unity to be most helpful in getting over this hurdle, written by NoCakeNoCode. If you're using Unity, you'll probably find it even more helpful than I did. ๐Ÿ‘

We have several variables to add:

  • engine_moment - Moment of inertia of the engine internals (eg. 0.25)
  • drive_inertia - engine_moment multiplied by the current gearing
  • engine_brake - Base value for engine braking/drag (eg. 10.0)
  • engine_drag - Drag that increases linearly with RPM (eg. 0.03)
  • torque_out - Stores the value we get from calling the torque() function above
  • drag_torque - Influence of engine_brake and engine_drag
  • rpm_limit - Also found above in the torque curve loop

My engine operation loop goes like this. First, I calculate drag_torque and grab the current torque_out. But there's something you might not expect:

drag_torque = engine_brake + rpm * engine_drag
torque_out = (torque() + drag_torque) * throttle

Why did NoCakeNoCode add drag_torque? To be perfectly honest, I can't think of a good physical explanation, but I can't argue with the results.

Now we apply the torque to the engine itself. I keep a constant called AV_2_RPM (= 60 / TAU, or 60 / (2 * PI)) to convert easily between angular velocity and RPM:

rpm += AV_2_RPM * delta * (torque_out - drag_torque) / engine_moment
if rpm >= rpm_limit:
    torq_out = 0  # Hit the limiter
    rpm -= 500  # You can make this a per-car variable if you like

Following NoCakeNoCode's example, the next part forks depending on whether the drivetrain is engaged or not. If the transmission is in neutral (or the clutch is depressed, if you're using a clutch input), simply rev the engine. Otherwise, apply the torque through the drivetrain. I use a method dedicated to each state.

In each case, the thing to do is get the average angular velocity of the drivewheels (reminder: I call it "spin" for brevity). That goes into determining both the speedometer readout (in meters per second, to be converted at the tail end to km/h or MPH) and the engine RPM for the next frame. Now is also the time to send torque to the SpringArm node (apply_torque()), which connects back to part IV above. The apply_torque() call sends the net driving torque, drive_inertia (which can be set after each gear change), and braking force (which I distribute in an array).

For simplicity, I will keep the examples to just RWD. Freewheeling is easy. We've already revved the engine, so just roll on and update the speedometer:

func freewheel(delta):
   var avg_spin = 0
   for w in range(4):
       # On the SpringArm's end: apply_torque(drive, drive_inertia, brake_torque, delta)
       wheel[w].apply_torque(0.0, 0.0, brake_torque[w], delta)
   for w in range(2,4):  # RWD
       avg_spin += wheel[w].spin * 0.5
   speedo = avg_spin * wheel[2].radius  # wheel 2 is rear left

When the drivetrain is engaged, calculate the net driving torque by multiplying the output and drag by the gear ratio. You will want to eliminate the drag once the drivewheels have come to a stop, or else drag will accelerate the car backwards. ๐Ÿ˜ To do so, I just add back the drag_torque multiplied by the gear ratio.

There's still another step after this, because we need to split the torque at the differential in the rwd() method. However, you will see that we update the RPM by reading back the average spin, multiplied by the gearing and AV_2_RPM. But we're not done with that either:

func engage(delta):
   var avg_spin = 0.0
   var net_drive = (torque_out - drag_torque) * gear_ratio()
   for w in range(2,4):  # RWD
       avg_spin += wheel[w].spin * 0.5
   if avg_spin * sign(gear_ratio()) < 0:
       net_drive += drag_torque * gear_ratio()
   rwd(net_drive, delta)
   speedo = avg_spin * wheel[2].radius
   for w in range(2):  # Just brakes for front wheels
       wheel[w].apply_torque(0.0, 0.0, brake_torque[w], delta)
   rpm = avg_spin * gear_ratio() * AV_2_RPM

Alright, now here is how I do a differential; when I call apply_torque() on the SpringArm, I have it return a ratio of the result versus the projected result if the wheel was free-spinning. In the main script, I sum up the two results from each side in a way that lets me determine which wheel is the "winner" of that frame. For an open differential, as you might have guessed already, the "winner" gets a larger share of the torque next time. My limited-slip model is very basic as of yet; I just fix the torque split to 50/50 when applicable.

On the powertrain side, it goes like this:

func rwd(drive, delta):
    # if r_diff == 0 use r_split as is; open diff
    if r_diff == 1 and drive * sign(gear_ratio()) > 0:
        r_split = 0.5  # Simple 1-way LSD
    if r_diff == 2:
        r_split = 0.5  # Simple 2-way LSD
    var diff_sum = 0
    diff_sum -= wheel[2].apply_torque(drive * (1 - r_split), drive_inertia, brake_torque[2], delta)
    diff_sum += wheel[3].apply_torque(drive * r_split, drive_inertia, brake_torque[3], delta)
    r_split = 0.5 * (clamp(diff_sum, -1, 1) + 1)

Coming back to the SpringArm node, applying torque goes like this, incorporating part IV, and adding drive_inertia to the equation from before. You can also calculate rolling resistance and apply it here; just add it to brake_torque before you multiply it by sign(spin):

func apply_torque(drive, drive_inertia, brake_torque, delta):
    var prev_spin = spin
    # Initialize net_torque with previous frame's friction
    var net_torque = z_force * radius
    # Apply drive torque
    net_torque += drive
    # Stop wheel if brakes overwhelm other forces
    if abs(spin) < 5 and brake_torque > abs(net_torque):  spin = 0
    else:
        net_torque -= brake_torque * sign(spin)
        spin += delta * net_torque / (wheel_moment + drive_inertia)
    # Return result for differential simulation
    if drive * delta == 0:  # Don't divide by zero
         return 0.5
    else:
        return (spin - prev_spin) * (wheel_moment + drive_inertia) / (drive * delta)

Finally, we can spin the wheels and get our RPM...but what happened to idling or easing into 1st gear? That's one of the insights I got from the Unity tutorial -- the clutch can be done after everything else in a frame, no matter how advanced or simple it is. NoCakeNoCode also gives an example of a cleverly simple way to do a clutch. In his own words, "just pretend we are at a higher rpm."

We only need two more variables for that. A minimum engine RPM (ie. idle or stall RPM), and an RPM for pseudo-slipping the clutch, which can be a fixed variable, or different for each engine.

var clutch_rpm = rpm_idle
if gear == 1:
    clutch_rpm += throttle * clutch_out_rpm
rpm = max(rpm, clutch_rpm)

I love how simple it is. Suppose rpm_idle is 750 and clutch_out_rpm is 2500. When in first gear at low speeds, pressing the throttle will bring the revs up to a limit of 3250. The car will then accelerate using the torque available in that range, holding whatever RPM it has until the RPM exceeds 3250, after which the drivetrain is engaged like normal -- creating an effect that sounds and appears like slipping the clutch. In the same line, the RPM is held at the idle speed if the car is stopped.

I'm pretty sure there are games that have done that for a clutch and fooled me into thinking they had more of a clutch simulation. ๐Ÿ™‚

โญ๏ธ Brushing Up and Misc. Details โญ๏ธ

Get GDSim

Download NowName your own price

Leave a comment

Log in with itch.io to leave a comment.