Wheel Controller Manager
WheelControllerManager coordinates every enabled WheelController and runs wheel physics in
substeps. Game code supplies control values; the manager owns simulation timing.
The runtime types on this page are in the NWH.WheelController3D namespace.
Lifecycle
WheelControllerManager.Instance finds the existing manager or creates a
DontDestroyOnLoad GameObject named WheelControllerManager. A standalone scene does not need a
manually created manager.
An enabled wheel registers with the manager under its targetRigidbody. Wheels that target the
same Rigidbody share one WheelControllerGroup. When the last wheel leaves a group, the manager
removes that group.
The relevant execution orders are:
| Component | Order | Role |
|---|---|---|
WheelController |
100 | Register and prepare wheels |
WheelControllerManager |
110 | Run the grouped substep loop |
Substep rate
targetEffectiveRate is the global target rate in hertz and defaults to 200. For each Rigidbody
group, the manager calculates:
target count = ceil(targetEffectiveRate * Time.fixedDeltaTime - 0.001)
safety count = ceil(Time.fixedDeltaTime / 0.05)
substeps = clamp(max(target count, safety count), 1, 8)
substep delta time = Time.fixedDeltaTime / substeps
For example, a 200 Hz target at Unity's 50 Hz fixed rate produces four wheel substeps. At 60 Hz it also produces four, for an effective wheel rate of 240 Hz. The ceiling keeps the effective rate at or above the requested value except when the eight-substep cap is reached; the small tolerance prevents floating-point noise from adding a substep at an exact integer ratio. The safety count also prevents a wheel substep from exceeding 0.05 seconds when the project's fixed timestep is larger than that.
Set WheelControllerGroup.targetEffectiveRateOverride to a positive value for a per-Rigidbody
target. Zero or a negative value uses the manager's global rate.
using NWH.WheelController3D;
using UnityEngine;
public sealed class WheelRateOverride : MonoBehaviour
{
[SerializeField] private Rigidbody targetRigidbody;
[SerializeField] private float targetRate = 200f;
private void Start()
{
WheelControllerGroup group =
WheelControllerManager.Instance.GetGroup(targetRigidbody);
if (group != null)
{
group.targetEffectiveRateOverride = targetRate;
}
}
}
Call GetGroup() after the wheels have enabled and registered. It returns null when the
Rigidbody has no registered wheels.
What happens in a physics tick
For each non-sleeping group, the manager:
- reads the Rigidbody into the group's cached
WheelControllerState; - chooses that group's substep count;
- begins each substep and checks early wake conditions;
- invokes the optional
ISubstepCallback; - calls the internal wheel substep for every wheel not already stepped by that callback;
- coordinates static-friction break and anti-roll-bar forces;
- integrates the cached state; and
- writes the final velocity, wheel transforms, and contacted-body forces.
Application code should not call an internal wheel step method. Assign motorTorque,
brakeTorque, and steerAngle before the manager's execution point and let the manager advance
the wheels.
Optional substep callback
WheelControllerGroup has one substepCallback. The current interface signature is:
void OnBeforeSubstep(float substepDt, WheelControllerGroup group);
NWH Vehicle Physics 2 uses this callback for its powertrain. Do not replace that callback on a
Vehicle Physics vehicle. A standalone advanced integration may implement
ISubstepCallback when it must calculate torque at the same rate as the wheels:
using NWH.WheelController3D;
using UnityEngine;
public sealed class StandaloneSubstepCallback : MonoBehaviour, ISubstepCallback
{
[SerializeField] private Rigidbody targetRigidbody;
private WheelControllerGroup _group;
private void Start()
{
_group = WheelControllerManager.Instance.GetGroup(targetRigidbody);
Attach();
}
private void OnEnable()
{
// OnEnable runs before Start the first time, so _group can still be null.
Attach();
}
private void OnDisable()
{
Detach();
}
private void OnDestroy()
{
Detach();
}
private void Attach()
{
if (_group != null)
{
_group.substepCallback = this;
}
}
private void Detach()
{
if (_group != null && _group.substepCallback == this)
{
_group.substepCallback = null;
}
}
public void OnBeforeSubstep(
float substepDt,
WheelControllerGroup group)
{
// Calculate and assign wheel torque for this group here.
}
}
Public manager API
| Member | Purpose |
|---|---|
Instance |
Find or create the singleton |
targetEffectiveRate |
Global target wheel rate in hertz |
substepCount |
Substep count calculated for the most recently processed group |
Register(WheelController) |
Register a wheel under its target Rigidbody |
Deregister(WheelController) |
Remove a wheel from its current group |
GetGroup(Rigidbody) |
Return the group for a Rigidbody, or null |
BreakAllStaticFriction(Rigidbody, bool) |
Break static friction for that group's grounded wheels |
ResetWheelState(Rigidbody) |
Clear spring travel, wheel spin, load and contact state on that group |
CalculateSubstepCount(float, float) |
Static; the substep count for a target rate and fixed timestep |
Call ResetWheelState() after teleporting or respawning a vehicle. Without it the wheels keep the
suspension pose, spin and contact state they had before the jump, which usually shows up as a
vehicle that lurches or spins its wheels for a moment at the new position.
The public static timing fields are physicsFrameCount, currentSubstep, substepDeltaTime,
FixedDeltaTime, Gravity, and GravityMagnitude. Because different groups may use different
rates, substepCount, currentSubstep, and substepDeltaTime describe the group currently being
processed, or the last one processed.
Standalone controls
The Base Sample's CarController writes the current API directly:
_Wheel.powerselects wheels that receivemotorTorque;_Wheel.steerselects wheels that receivesteerAngle; and_Wheel.handbrakeselects wheels that receive handbrakebrakeTorque.
It does not call a wheel simulation method. See Car Controller and Input.
Troubleshooting
GetGroup() returns null
Confirm that at least one enabled Wheel Controller targets that Rigidbody, then query after wheel
OnEnable() has run.
More substeps are not being used
The manager clamps the calculated count to 1 through 8. Confirm Time.fixedDeltaTime, the global
targetEffectiveRate, and any positive group override.
A sleeping vehicle does not consume wheel time
This is expected. A group whose Rigidbody can remain asleep is skipped until wheel torque or another wake condition requires processing.