@r_outsider -- Wow, thanks! That's really cool of you.
I've had one person PM me about the next entry, and I'm sorry it has taken so long. So without further ado:
V. 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:
Code:
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:
Code:
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:
Code:
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:
Code:
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:
Code:
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:
Code:
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):
Code:
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.
Code:
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.
EDIT 8/17/21: Fixed a typo in apply_torque() that omitted the drive torque, and changed a line in determining the differential split.
1/11/22: Added a missing abs() to brake lock condition