本博客实现的代码以 Unity Asset Store 中 USHISOFT 的资源代码 WheelCollider2 为参考
本博客内容较多,代码量较大,可以直接点击右侧目录跳转到你想看的地方
整体代码
变量与属性查询
变量
变量名称
变量解释
SweepType
碰撞检测的形状。 射线(如 WheelCollider
),使用单束光检测与地面的碰撞。可能是最快的,但在有小孔的地面上可能会卡住。 球体,检测球体和地面之间的碰撞。 圆柱体,使用最接近轮胎形状的圆柱体来确定与地面的碰撞。推荐使用。
Radius
轮胎半径 (m)。
Inertia
轮胎惯性矩。 惯性矩是衡量物体转动或停止难度的指标。
Width
轮胎宽度 (m)。用于 SweepType/Cylinder
。
Cylinder Resolution
用于 SweepType/Cylinder
的圆柱体网格平滑度。
Suspension Spring
悬挂弹簧常数 (Nm)。
Suspension Bump
悬挂压缩侧的阻尼力 (Nm)。
Suspension Rebound
悬挂回弹侧的阻尼力 (Nm)。
Suspension Distance
悬挂长度 (m)。
Camber Angle
倾角 (度)。 对于左侧轮胎,设置负值会导致负倾角,设置正值会导致正倾角。 对于右侧轮胎,正负值需要反转。如果你想让右侧轮胎有负倾角,设置正值。
Toe Angle
转向角度 (度)。 对于左侧轮胎,负值会导致内束角,正值会导致外束角。 对于右侧轮胎,正负值需要反转。如果你想让右侧轮胎有内束角,设置正值。
Magic Formula B, C, D, E
魔术公式(简化版)的系数,用于计算摩擦力。 通过调整这些系数,可以模拟各种路面。 D 是摩擦系数。
Rolling Resistance Coef
滚动阻力系数。
Bump Amplitude
地面不平整度 (m)。用于模拟砾石路和赛道旁草地等不平整路面。
Bump Period
地面不平整周期。周期越大,不平整度越短。建议增加细颗粒路面如砾石路的周期大小。
Model
轮胎的 3D 模型。配置的 3D 模型将根据此组件的状态进行动画处理。即使不设置也没有问题。
属性
属性名称
属性解释
SteerAngle
转向角度 (度)
DriveTorque
驱动扭矩 (Nm)。等同于 WheelCollider
的 MotorTorque
BrakeTorque
制动扭矩 (Nm)
DrivetrainInertia
传动系统惯性矩。驱动轮具有较大的惯性矩,因为它们与传动系统相连。设置这个值对于实现逼真的行为至关重要
DrivetrainFrictionTorque
传动系统摩擦扭矩 (Nm)
AntiRollBarForce
防倾杆力 (Nm)
Grounded
是否接地
HitInfo
接地点信息
CurrentSuspensionDistance
当前悬挂长度 (m)。
SuspensionCompressionRate
悬挂压缩率。
AngularVelocity
轮胎角速度 (rad/s)。
RPM
轮胎转速 (rpm)。
ForwardDirection
纵向摩擦力作用方向。对应 WheelCollider
的 GetGroundHit
获取的 forwardDir
。
ForwardSlip
纵向滑移率。对应 WheelCollider
的 GetGroundHit
获取的 forwardSlip
。
ForwardForce
垂直方向上的摩擦力 (Nm)。
SidewaysDirection
横向摩擦力作用方向。对应 WheelCollider
的 GetGroundHit
获取的 sidewaysDir
。
SidewaysSlip
横向滑移率。对应 WheelCollider
的 GetGroundHit
获取的 sidewaysSlip
。
SidewaysForce
横向摩擦力 (Nm)。
Load
轮胎负载 (Nm)。对应 WheelCollider
的 GetGroundHit
获取的力。
Center
轮胎中心坐标 (世界空间)。对应 WheelCollider
的 GetWorldPose
获取的 pos
。
Rotation
轮胎旋转 (世界空间)。等同于 WheelCollider
的 GetWorldPose
获取的 quat
。
PeakSlip
摩擦力最大时的滑移率。
变量解释
在开始主要程序的编写之前,我们需要先对 WheelCollider2
所需的变量有一个基本的了解
SweepType 部分
1 2 3 4 5 6 7 8 public enum SweepType{ Ray, Sphere, Cylinder, } [SerializeField ] private SweepType _sweepType = SweepType.Ray;
SweepType
指的是车轮与地面碰撞检测的三种类型,这里通过枚举让玩家可以自由选择使用哪种方式来配置 WheelCollider2
射线检测(Ray
)是指通过单根射线与地面碰撞来实现检测,是性能最优的方法(也是传统 WheelCollider
提供的方法),但很容易卡在地面的小洞中,此时射线无法检测到地面而判断轮胎在空中,从而无法提供动力(哪怕车轮的其他部位都在地面上)。
球体检测(Sphere
)是将车轮模拟为一个球体来与地面进行碰撞,是许多游戏模型常用的方法(将车轮的碰撞体用球体代替)。
圆柱体检测(Cylinder
)是将车轮模拟为一个侧面着地圆柱体(最符合实际的情况),也是在该类中最推荐的选择。
常量部分
1 2 3 private const float MinDenominator = 10f ; private const float MinDenominator2 = 0.00001f ; private const int GizmosSmoothness = 16 ;
此处的两个 MinDenominator
常量均用于防止在后面的变量中出现除以零错误,举个例子:
1 2 3 4 5 6 7 8 float force = CalculateForce();float mass = GetMass();float acceleration = force / Mathf.Max(mass, MinDenominator);float preciseAcceleration = force / Mathf.Max(mass, MinDenominator2);
通过 Mathf.Max()
方法将可能很小的变量用 MinDenominator
替代,在一定程度上保证精确度的同时避免除以零错误的产生。
车轮几何部分
1 2 3 4 5 [SerializeField, Min(0.001f) ] private float _radius = 0.3f ; [SerializeField, Min(0.001f) ] private float _inertia = 1f ; [SerializeField, Min(0.001f) ] private float _width = 0.2f ; [SerializeField, Min(8f) ] private int _cylinderResolution = 16 ;
_radius
车轮半径,影响车辆的高度和行驶性能,需要进行初始设定以适配轮胎模型的大小和位置,通过 Min(0.001f)
确定最小值为 0.001。
_inertia
车轮质量惯性矩,影响车轮旋转惯性和响应速度。同样最小值为 0.001。
_width
车轮宽度,当选择 SweepType
为圆柱体检测时设置圆柱体的侧边高度(即轮胎宽度),影响抓地力和视觉效果。
_cylinderResolution
车轮圆柱分辨率,当选择 SweepType
为圆柱体检测时设定轮胎形状的细分程度,影响后续的绘制渲染质量和碰撞检测精度,更高的车轮分辨率意味着车轮更“圆”,更贴合实际轮胎情况,在模拟时可以提供更好的碰撞检测精度,但更高分辨率也会加大性能消耗,建议直接采用默认的 16 即可。
悬挂系统部分
1 2 3 4 [SerializeField, Min(0f) ] private float _suspensionSpring = 40000f ; [SerializeField, Min(0f) ] private float _suspensionBump = 2000f ; [SerializeField, Min(0f) ] private float _suspensionRebound = 2000f ; [SerializeField, Min(0.001f) ] private float _suspensionLength = 0.1f ;
_suspensionSpring
悬挂系统的弹簧力度,值越大,悬挂到达目标位置的速度越快,车辆在不平路面上的反应更快,但也会传递更多的路面振动到车身;值越小,悬挂更软,可以更好地吸收路面不平带来的冲击,但在快速转弯时可能会导致车身过度侧倾。
_suspensionBump
悬挂系统的压缩阻尼系数,即悬挂压缩时的能量消耗速度。值越大,悬挂压缩速度越慢,减少车身在撞击障碍物时的上下跳动,有助于保持轮胎与地面的良好接触;值越小,悬挂压缩越快,允许车身更快地响应路面变化,但可能导致车辆在颠簸路面上的跳跃感增加。
_suspensionRebound
悬挂系统的回弹阻尼系数,即悬挂从压缩状态恢复到原始位置时的能量消耗速度。值越大,悬挂回弹越慢,减少车身在压缩后迅速反弹引起的震荡,有助于稳定车身姿态;值越小,悬挂回弹越快,允许车身迅速回复到正常高度,但可能导致车辆在颠簸路面上的频繁跳跃。
_suspensionLength
悬挂系统的最大行程长度,即悬挂能够压缩和拉伸的最大距离。值越大,悬挂就能够在更大范围内运动,适合越野环境,能够更好地应对较大的路面起伏;值越小,悬挂运动范围越有限,适合平坦道路,提供更精确的操控反馈。
车轮倾角部分
1 2 [SerializeField, Range(-45f, 45f) ] private float _camberAngle = 0f ; [SerializeField, Range(-45f, 45f) ] private float _toeAngle = 0f ;
内倾角 (Camber Angle, _camberAngle
)
定义:车轮垂直中心线相对于车辆纵向垂直平面的倾斜角度。负内倾角表示车轮顶部向内倾斜,正内倾角表示车轮顶部向外倾斜。
负内倾角:增加外侧轮胎在转弯时的接地面积,提高抓地力,适合高性能驾驶和赛道使用。
正内倾角:减少外侧轮胎在转弯时的接地面积,降低抓地力,但可能改善直线行驶的稳定性。
零内倾角:车轮完全垂直于地面,适合日常驾驶,提供均衡的操控和轮胎寿命。
前束角 (Toe Angle, _toeAngle
)
定义:车轮前端相对于车辆横向轴线的偏移角度。前束(Toe-in)表示车轮前端向内靠拢,外倾(Toe-out)表示车轮前端向外分开。
前束(Toe-in):使车辆在高速行驶时更加稳定,减少转向不足,适合长途驾驶。
外倾(Toe-out):使车辆更容易转向,增加转向灵敏度,适合赛车和需要快速转向响应的场景。
零前束角:车轮完全平行于车辆横向轴线,适合大多数日常驾驶情况,提供均衡的操控性能和轮胎寿命。
如果是制作城市等公路使用的普通车辆,此处推荐直接使用默认的 0 内倾角、0 前束角。
魔术公式部分
魔术公式是由荷兰 Delft 理工大学 H.B.Pacejke 教授等人提出并发展起来的,它是用三角函数的组合公式建立的轮胎的纵向力、侧向力和回正力矩的数学模型,因只用一套公式就完整地表达了纯工况下轮胎的力特性,故称为“魔术公式”。
1 2 3 4 [SerializeField ] private float _magicFormulaB = 10f ; [SerializeField ] private float _magicFormulaC = 1.65f ; [SerializeField, Min(0f) ] private float _magicFormulaD = 1.5f ; [SerializeField ] private float _magicFormulaE = 0.97f ;
Magic Formula B (_magicFormulaB
)
Magic Formula 中的一个形状因子(Shape Factor),它影响了该模型中力-滑移关系曲线的宽度。
较大的 _magicFormulaB
值会使力-滑移曲线更宽,意味着在较大滑移率下仍然能保持较高的抓地力。
较小的 _magicFormulaB
值会使曲线更窄,表示轮胎在较小的滑移率范围内就能达到最大抓地力,但超过这个范围后抓地力迅速下降。
Magic Formula C (_magicFormulaC
)
Magic Formula 中的另一个形状因子,主要控制力-滑移曲线的峰值位置。
它决定了轮胎在什么滑移率时能达到最大抓地力。
对于不同的轮胎和驾驶条件,优化 _magicFormulaC
可以使轮胎在最需要的时候提供最佳抓地力。
Magic Formula D (_magicFormulaD
)
Magic Formula 中的峰值因子(Peak Factor),代表了轮胎所能提供的最大侧向力或纵向力的比例。
较高的 _magicFormulaD
值意味着轮胎能够产生更大的力,即更高的抓地力。
该值应根据实际轮胎性能进行调整,过高或过低都会导致模拟不准确。
限制:最小值为 0f,确保非负值。
Magic Formula E (_magicFormulaE
)
Magic Formula 中的曲率因子(Curvature Factor),影响了力-滑移曲线的斜率。
它决定了曲线在接近峰值时的变化速度,从而影响轮胎从线性区域过渡到非线性区域的行为。
较小的 _magicFormulaE
值可以使曲线更加平缓,表示轮胎在接近极限时仍有较好的渐进性;较大的值则会使曲线更陡峭,表示轮胎一旦超过一定滑移率就迅速失去抓地力。
名词解释:滑移率 (Slip Ratio)
滑移率是指车轮的旋转速度与其线速度之间的差异比率,主要用于描述车轮的纵向滑动情况。它分为两种类型:
纵向滑移率(Longitudinal Slip Ratio):用于描述刹车或加速时车轮的滑动情况,指车轮在旋转时,其实际速度与理论线速度之间的差异比例。
侧向滑移率(Lateral Slip Ratio) 或 侧偏角(Slip Angle):用于描述转向时车轮的侧向滑动情况,指车轮沿侧向的速度分量与车辆行驶速度的比例。它反映了车辆转弯时轮胎是否能够跟随预期路径。
名词解释:力-滑移关系曲线
力-滑移关系曲线展示了轮胎产生的力(如侧向力、纵向力)随着滑移率或侧偏角变化的关系。这条曲线是非线性的,并且具有以下特点:
线性区域:当滑移率或侧偏角较小时,轮胎产生的力与滑移率成正比增加。此时,轮胎处于稳定工作状态,抓地力良好。
峰值点:随着滑移率或侧偏角的增加,轮胎产生的力达到最大值。这个最大值对应于最佳抓地力点,通常发生在一定的滑移率范围内。
非线性区域:超过峰值点后,轮胎产生的力迅速下降,即使滑移率继续增加。这表明轮胎已经失去抓地力,进入了打滑或漂移状态。
魔术公式的详细数学原理:
H.B.Pacejke 轮胎模型(魔术公式)
Tire-Road Interaction (Magic Formula)
在此不详细叙述其数学原理部分,Magic Formula B、C、D、E 在公式中的应用会在后续的函数中提及。
轮胎滚动阻力和路面影响
1 2 3 4 5 [SerializeField, Min(0f) ] private float _rollingResistanceCoef = 0.015f ; [SerializeField, Min(0f) ] private float _bumpAmplitude = 0f ; [SerializeField, Min(0f) ] private float _bumpPeriod = 0f ;
滚动阻力系数 (_rollingResistanceCoef
)
定义轮胎的滚动阻力系数,这是一个无量纲的比例因子,用于计算滚动阻力。
滚动阻力是轮胎在滚动时由于变形、摩擦等因素而产生的阻力。它直接影响车辆的燃油效率和加速性能。
较高的 _rollingResistanceCoef
值意味着更大的滚动阻力,导致车辆需要更多的能量来维持速度,影响加速性和油耗。
较低的值则表示较小的滚动阻力,有助于提高燃油经济性和加速性能,但可能降低抓地力。
颠簸幅度 (_bumpAmplitude
)
定义路面上颠簸的最大高度(振幅),用于模拟不平整路面的效果。
这个参数用于模拟道路上的起伏,如减速带、坑洼或其他障碍物。
较大的 _bumpAmplitude 值表示更剧烈的路面变化,可能导致车辆更频繁和剧烈的上下运动。
设置为 0f 表示没有明显的路面不平度,适用于模拟平坦的道路条件。
颠簸周期 (_bumpPeriod
)
定义路面上颠簸之间的距离(波长),用于控制颠簸的频率。
这个参数决定了颠簸出现的频率。较大的 _bumpPeriod 值表示颠簸之间的距离较长,车辆经过两次颠簸的时间间隔较大;较小的值则表示颠簸更加密集。
设置为 0f 表示没有明确的周期性颠簸,适用于模拟随机或无规律的路面状况。
交互部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 [SerializeField ] private Transform _model; [SerializeField ] private bool _debug = true ; private Rigidbody _rigidbody;private CylinderCaster _cylinderCaster;private float _peakSlip;private bool _grounded;private RaycastHit _hitInfo;private Vector3 _contactPosition;
其中,CylinderCaster
为自定义类,详细信息可以在 自制载具系统:CylinderCaster 中找到
车辆行为内部变量
此部分变量为 WheelCollider2
运行时发生变化的内部变量,用来记录车辆运行时的部分参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 private float _suspensionCompression; private float _suspensionPrevCompression; private float _antiRollBarForce; private Vector3 _forwardDirection; private Vector3 _sidewaysDirection; private float _currentCamberAngle; private float _forwardVelocity; private float _sidewaysVelocity; private float _forwardSlip; private float _sidewaysSlip; private float _slipMagnitude; private float _load; private float _forwardForce; private float _sidewaysForce; private float _angleInRadian; private float _angularVelocity; private float _steerAngle; private float _driveTorque; private float _brakeTorque; private float _drivetrainInertia; private float _drivetrainFrictionTorque;
名词解释:防倾杆
防倾杆(Anti-roll bar),也被称为稳定杆或抗侧倾杆,是一种用于减少车辆在转弯时车身侧倾的悬挂系统组件。它通过连接左右两侧的悬挂来平衡车轮的运动,从而改善车辆的操控性和稳定性。
防倾杆通常由一根扭力杆构成,两端分别连接到车辆两侧的悬挂臂上,并且中间固定在车架或副车架上。当车辆转弯时,外侧悬挂会受到向上的力而拉伸,内侧悬挂则受到向下的力而压缩。防倾杆的作用就是在这两个悬挂之间传递扭转力,以抵抗这种差异,减小车身侧倾的程度。
变量读写权限
可读可写部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 public Transform Model{ get => _model; set => _model = value ; } public float Radius{ get => _radius; set => _radius = Mathf.Max(value , 0.001f ); } public float Inertia{ get => _inertia; set => _inertia = Mathf.Max(value , 0.001f ); } public float Width{ get => _width; set => _width = Mathf.Max(value , 0.001f ); } public int CylinderResolution{ get => _cylinderResolution; set => _cylinderResolution = Mathf.Max(value , 8 ); } public float SuspensionSpring{ get => _suspensionSpring; set => _suspensionSpring = Mathf.Max(value , 0f ); } public float SuspensionBump{ get => _suspensionBump; set => _suspensionBump = Mathf.Max(value , 0f ); } public float SuspensionRebound{ get => _suspensionRebound; set => _suspensionRebound = Mathf.Max(value , 0f ); } public float SuspensionLength{ get => _suspensionLength; set => _suspensionLength = Mathf.Max(value , 0.001f ); } public float CamberAngle{ get => _camberAngle; set => _camberAngle = Mathf.Clamp(value , -45f , 45f ); } public float ToeAngle{ get => _toeAngle; set => _toeAngle = Mathf.Clamp(value , -45f , 45f ); } public float MagicFormulaB{ get => _magicFormulaB; set => _magicFormulaB = value ; } public float MagicFormulaC{ get => _magicFormulaC; set => _magicFormulaC = value ; } public float MagicFormulaD{ get => _magicFormulaD; set => _magicFormulaD = Mathf.Max(value , 0f ); } public float MagicFormulaE{ get => _magicFormulaE; set => _magicFormulaE = value ; } public float RollingResistanceCoef{ get => _rollingResistanceCoef; set => _rollingResistanceCoef = Mathf.Max(value , 0f ); } public float BumpAmplitude{ get => _bumpAmplitude; set => _bumpAmplitude = Mathf.Max(value , 0f ); } public float BumpPeriod{ get => _bumpPeriod; set => _bumpPeriod = Mathf.Max(value , 0f ); } public float SteerAngle{ get => _steerAngle; set => _steerAngle = value ; } public float DriveTorque{ get => _driveTorque; set => _driveTorque = value ; } public float BrakeTorque{ get => _brakeTorque; set => _brakeTorque = Mathf.Max(value , 0f ); } public float DrivetrainInertia{ get => _drivetrainInertia; set => _drivetrainInertia = Mathf.Max(value , 0f ); } public float DrivetrainFrictionTorque{ get => _drivetrainFrictionTorque; set => _drivetrainFrictionTorque = Mathf.Max(value , 0f ); } public float AntiRollBarForce{ get => _antiRollBarForce; set => _antiRollBarForce = value ; }
只读部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 public bool Grounded{ get => _grounded; } public RaycastHit HitInfo{ get => _hitInfo; } public Vector3 ContactPosition{ get => _contactPosition; } public float CurrentSuspensionLength{ get => _suspensionLength - _suspensionCompression; } public float SuspensionCompressionRate{ get => _suspensionCompression / _suspensionLength; } public float AngularVelocity{ get => _angularVelocity; } public float RPM{ get => _angularVelocity * EngineMath.RPSToRPM; } public Vector3 ForwardDirection{ get => _forwardDirection; } public float ForwardForce{ get => _forwardForce; } public float ForwardSlip{ get => _forwardSlip; } public float SlipMagnitude{ get => _slipMagnitude; } public Vector3 SidewaysDirection{ get => _sidewaysDirection; } public float SidewaysForce{ get => _sidewaysForce; } public float SidewaysSlip{ get => _sidewaysSlip; } public float Load{ get => _load; } public Vector3 Center{ get => transform.position - transform.up * CurrentSuspensionLength; } public Quaternion Rotation{ get => transform.rotation * Quaternion.Euler(0f , _steerAngle + _toeAngle, 0f ) * Quaternion.Euler(0f , 0f , _camberAngle) * Quaternion.Euler(_angleInRadian * Mathf.Rad2Deg, 0f , 0f ); } public float PeakSlip{ get => _peakSlip; }
方法部分
初始化方法 Awake
固定每帧更新方法 FixedUpdate
绘制辅助图形方法 OnDrawGizmos
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 private void OnDrawGizmos (){ Gizmos.color = Color.green; Gizmos.DrawLine(transform.position, Center); Gizmos.matrix = Matrix4x4.TRS(Center, Rotation, transform.lossyScale); switch (_sweepType) { case SweepType.Ray: DrawCircle(0f ); Gizmos.DrawLine(Vector3.zero, GetLocalPointOnCircumference(0f , 0f )); break ; case SweepType.Sphere: Gizmos.DrawWireSphere(Vector3.zero, _radius); break ; case SweepType.Cylinder: DrawCircle(-_width / 2f ); DrawCircle(_width / 2f ); Gizmos.DrawLine(Vector3.zero, GetLocalPointOnCircumference(0f , 0f )); break ; } Gizmos.matrix = Matrix4x4.identity; } private void DrawCircle (float x ){ for (var i = 0 ; i < GizmosSmoothness; i++) { var angleInRad1 = 2f * Mathf.PI * (float )i / (float )GizmosSmoothness; var angleInRad2 = 2f * Mathf.PI * (float )(i + 1 ) / (float )GizmosSmoothness; Gizmos.DrawLine( GetLocalPointOnCircumference(angleInRad1, x), GetLocalPointOnCircumference(angleInRad2, x)); } } private Vector3 GetLocalPointOnCircumference (float angleInRadian, float x ){ return new Vector3( x, Mathf.Sin(angleInRadian) * _radius, -Mathf.Cos(angleInRadian) * _radius); }
绘制悬挂
1 Gizmos.DrawLine(transform.position, Center);
从车轮的当前位置(transform.position)到计算出的车轮中心位置(Center)之间绘制一条直线。
注意:由于 WheelCollider2 需要绑定在一个相对车辆固定的 Empty 物体上,所以此处的 transform.position
实际上获取的是车轮固定在车辆上的位置(相当于初始悬挂位置,不受悬挂影响),而 Center
则代表车轮计算出的当前位置(受到悬挂影响),所以二者连线可以表示悬挂对轮胎的位置影响
设置矩阵以应用 transform 变换
1 Gizmos.matrix = Matrix4x4.TRS(Center, Rotation, transform.lossyScale);
设置当前Gizmos的变换矩阵(Matrix),以应用位置(Translation)、旋转(Rotation)和缩放(Scale)。
TRS
是 “Translation, Rotation, Scale” 的缩写,表示依次应用平移、旋转和缩放。
Center
是之前计算得到的车轮实际中心位置。
Rotation
是车轮的完整旋转状态。
transform.lossyScale
获取了对象的累积缩放值,确保Gizmos正确反映物体的实际大小。
绘制 Gizmos
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 switch (_sweepType){ case SweepType.Ray: DrawCircle(0f ); Gizmos.DrawLine(Vector3.zero, GetLocalPointOnCircumference(0f , 0f )); break ; case SweepType.Sphere: Gizmos.DrawWireSphere(Vector3.zero, _radius); break ; case SweepType.Cylinder: DrawCircle(-_width / 2f ); DrawCircle(_width / 2f ); Gizmos.DrawLine(Vector3.zero, GetLocalPointOnCircumference(0f , 0f )); break ; }
此处根据不同的扫掠类型(SweepType
),选择绘制不同形状的Gizmos。
SweepType.Ray
:绘制一个圆形,并从原点到轮胎边缘上弧度为 0 处的点绘制一条线,这条线会随着车轮旋转而旋转,可以用来可视化车轮旋转。
SweepType.Sphere
:绘制一个空心球体(Wire Sphere),其半径为 _radius。这可以用来可视化轮胎的碰撞体积。
SweepType.Cylinder
:绘制两个圆形,分别位于宽度的两侧(-_width / 2f
和 _width / 2f
),并从原点到轮胎边缘上弧度为 0 处的点绘制一条线,这条线会随着车轮旋转而旋转,可以用来可视化车轮转动和转过的角度。
重置变换矩阵
1 Gizmos.matrix = Matrix4x4.identity;
可以确保后续的 Gizmos 绘制不会受到之前设置的变换矩阵的影响,让 transform 的变换回到默认的世界坐标系。
DrawCircle(float)
方法
1 2 3 4 5 6 7 8 9 10 11 private void DrawCircle (float x ){ for (var i = 0 ; i < GizmosSmoothness; i++) { var angleInRad1 = 2f * Mathf.PI * (float )i / (float )GizmosSmoothness; var angleInRad2 = 2f * Mathf.PI * (float )(i + 1 ) / (float )GizmosSmoothness; Gizmos.DrawLine( GetLocalPointOnCircumference(angleInRad1, x), GetLocalPointOnCircumference(angleInRad2, x)); } }
作用:绘制一个圆,其中心位于 (x, 0, 0),半径为 _radius
。
循环遍历角度:for 循环迭代 GizmosSmoothness
次,而这决定了圆的平滑度(即使用多少个线段来近似表示圆)。值越大,绘制的圆越平滑。
angleInRad1
和 angleInRad2
分别是当前点和下一个点的角度(以弧度为单位),通过公式 2 * PI * i / GizmosSmoothness 计算得出。这确保了角度均匀分布在一个完整的圆周上(360 度或 2π 弧度)。
绘制线段:Gizmos.DrawLine
方法用于在两个连续的角度位置之间绘制一条直线。它调用了 GetLocalPointOnCircumference 来获取这些位置的坐标。
GetLocalPointOnCircumference(float angleInRadian, float x)
方法
1 2 3 4 5 6 7 private Vector3 GetLocalPointOnCircumference (float angleInRadian, float x ){ return new Vector3( x, Mathf.Sin(angleInRadian) * _radius, -Mathf.Cos(angleInRadian) * _radius); }
作用:根据给定的角度(弧度)和偏移量 x,返回圆周上的一个点的位置(Vector3)。
构造 Vector3:这个方法使用正弦和余弦函数来计算圆周上的点的 y 和 z 坐标,而 x 坐标则直接使用传入的参数 x。
Mathf.Sin(angleInRadian) * _radius
计算 y 坐标,即从圆心到该点的垂直距离。
-Mathf.Cos(angleInRadian) * _radius
计算 z 坐标,负号确保圆周按预期方向绘制(逆时针)。
传入的 x 直接作为 x 坐标,使得圆可以沿 x 轴偏移,从而绘制不同位置的圆周。
返回这个计算出的 Vector3 值。
扫描检测方法 Sweep
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 private void Sweep (){ switch (_sweepType) { case SweepType.Ray: _grounded = Physics.Raycast( transform.position, -transform.up, out _hitInfo, _suspensionLength + _radius); break ; case SweepType.Sphere: _grounded = Physics.SphereCast( transform.position + transform.up * _radius, _radius, -transform.up, out _hitInfo, _suspensionLength + _radius); break ; case SweepType.Cylinder: UpdateCylinderCaster(); _grounded = _cylinderCaster.Cast( -transform.up, out _hitInfo, _suspensionLength + _radius); break ; } } private void UpdateCylinderCaster (){ if (_sweepType != SweepType.Cylinder) { return ; } if (_cylinderCaster == null ) { var go = new GameObject("CylinderCast" ); go.transform.parent = transform; go.transform.localPosition = Vector3.up * _radius; _cylinderCaster = go.AddComponent<CylinderCaster>(); _cylinderCaster.Init(_radius, _width, _cylinderResolution); } if (_cylinderCaster.Radius != _radius || _cylinderCaster.Width != _width || _cylinderCaster.Resolution != _cylinderResolution) { _cylinderCaster.transform.localPosition = Vector3.up * _radius; _cylinderCaster.Init(_radius, _width, _cylinderResolution); } _cylinderCaster.transform.localEulerAngles = new Vector3( 0f , _steerAngle + _toeAngle, _camberAngle); }
该方法根据选择的 SweepType 的不同,分别选择不同的方式进行对地面的扫描。
SweepType.Ray 射线式扫描
1 2 3 4 5 6 7 case SweepType.Ray: _grounded = Physics.Raycast( transform.position, -transform.up, out _hitInfo, _suspensionLength + _radius); break ;
Physics.Raycast
方法接收到给出的变量,发出一条从 transform.position
开始沿 -transform.up
方向的射线。
射线的最大长度是 _suspensionLength + _radius
,这意味着射线会延伸到悬挂系统的最大伸展长度加上轮子半径的位置。
只有当物体在空中时,即使悬挂伸长到最大长度轮胎也无法触碰地面。所以如果射线击中了任何碰撞体,则表示物体不在空中, _grounded
变量会被设置为 true,表示物体接地;否则,它保持 false。
out _hitInfo
参数允许获取关于撞击点的额外信息(如位置、法线等),但此处并未获取。
SweepType.Sphere 球体式扫描
1 2 3 4 5 6 7 8 case SweepType.Sphere: _grounded = Physics.SphereCast( transform.position + transform.up * _radius, _radius, -transform.up, out _hitInfo, _suspensionLength + _radius); break ;
Physics.SphereCast
沿着 -transform.up
方向投射一个球体,起始于 transform.position + transform.up * _radius
,也就是从轮胎的上边缘点出发,沿向下方向投射一个半径为 _radius
的球体,此时球体中心会正好处于轮胎的几何中心。
射线的最大长度同样是 _suspensionLength + _radius
,这意味着射线会延伸到悬挂系统的最大伸展长度加上轮子半径的位置。
类似于 Raycast,SphereCast 也会更新 _grounded
和 _hitInfo
,基于是否有一个碰撞体在球体路径上。
SweepType.Cylinder 圆柱体式扫描
1 2 3 4 5 6 7 8 case SweepType.Cylinder: UpdateCylinderCaster(); _grounded = _cylinderCaster.Cast( -transform.up, out _hitInfo, _suspensionLength + _radius); break ;
在进行圆柱体检测之前,UpdateCylinderCaster()
方法被调用,可能是为了更新圆柱体检测器的状态或参数,使其适应当前物体的姿势或其他属性。
_cylinderCaster.Cast
执行一次圆柱体检测,沿 -transform.up
方向进行,最大检测距离为 _suspensionLength + _radius
。
结果同样更新 _grounded
和 _hitInfo
。
UpdateCylinderCaster()
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 private void UpdateCylinderCaster (){ if (_sweepType != SweepType.Cylinder) { return ; } if (_cylinderCaster == null ) { var go = new GameObject("CylinderCast" ); go.transform.parent = transform; go.transform.localPosition = Vector3.up * _radius; _cylinderCaster = go.AddComponent<CylinderCaster>(); _cylinderCaster.Init(_radius, _width, _cylinderResolution); } if (_cylinderCaster.Radius != _radius || _cylinderCaster.Width != _width || _cylinderCaster.Resolution != _cylinderResolution) { _cylinderCaster.transform.localPosition = Vector3.up * _radius; _cylinderCaster.Init(_radius, _width, _cylinderResolution); } _cylinderCaster.transform.localEulerAngles = new Vector3( 0f , _steerAngle + _toeAngle, _camberAngle); }
作用:初始化和更新用于圆柱体检测的 CylinderCaster
组件
先根据 SweepType
的值来确定是否需要使用该函数,如果不为圆柱体类型则直接跳过该函数。
初始化 CylinderCaster
类,如果检测不到该 WheelCollider2
对象含有 CylinderCaster
则新建一个并传入设定好的值进行初始化。
持续判断半径、宽度、分辨率三个条件是否与实际情况符合,如果不符合则重新确定位置并初始化。
更新 CylinderCaster
的本地旋转,应用车轮的转向、倾角和束角,其中倾角 CamberAngle 为车轮与地面之间的夹角与 90 度的差值,由车轮绕 Z 轴旋转得到;转向角为车辆转向时车轮的偏角,束角 ToeAngle 是指的我们从车辆正上方看车子的时候,轮胎与车辆中轴线形成的夹角,这两个角度由车轮绕 Y 轴(垂直地面轴)旋转得到。
更新轮胎状态方法 UpdateState
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 private void UpdateStates (){ _suspensionPrevCompression = _suspensionCompression; if (_grounded) { var localAddForcePos = transform.InverseTransformPoint(_hitInfo.point); localAddForcePos.x = 0f ; localAddForcePos.z = 0f ; _contactPosition = transform.TransformPoint(localAddForcePos); var localAngleY = _steerAngle + _toeAngle; var forward = Quaternion.AngleAxis(localAngleY, transform.up) * transform.forward; var right = Quaternion.AngleAxis(localAngleY, transform.up) * transform.right; _forwardDirection = Vector3.ProjectOnPlane(forward, _hitInfo.normal).normalized; _sidewaysDirection = Vector3.ProjectOnPlane(right, _hitInfo.normal).normalized; _currentCamberAngle = Vector3.SignedAngle(_hitInfo.normal, transform.up, forward) + _camberAngle; var vel = _rigidbody.GetPointVelocity(_hitInfo.point); if (_hitInfo.collider.attachedRigidbody != null ) { var contactVel = _hitInfo.collider.attachedRigidbody.GetPointVelocity(_hitInfo.point); vel -= contactVel; } _forwardVelocity = Vector3.Dot(vel, _forwardDirection); _sidewaysVelocity = Vector3.Dot(vel, _sidewaysDirection); var noiseX = _hitInfo.point.x * _bumpPeriod; var noiseY = _hitInfo.point.z * _bumpPeriod; var bump = (Mathf.PerlinNoise(noiseX, noiseY) - 0.5f ) * _bumpAmplitude; _suspensionCompression = _suspensionLength - (_hitInfo.distance + bump - _radius); } else { _contactPosition = Vector3.zero; _forwardDirection = Vector3.zero; _sidewaysDirection = Vector3.zero; _forwardVelocity = 0f ; _sidewaysVelocity = 0f ; _suspensionCompression = 0f ; } }
该方法负责更新车轮的各种状态变量,包括悬挂压缩、接触位置、方向向量(前向和侧向)、速度分量以及悬挂压缩的当前值。
保存当前悬挂压缩值
1 _suspensionPrevCompression = _suspensionCompression;
保存当前的悬挂压缩值 _suspensionCompression
到 _suspensionPrevCompression
。这使得在后续计算中可以比较当前帧和上一帧的悬挂压缩变化,有助于模拟悬挂系统的动态行为。
更新车轮触地时的状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 if (_grounded) { var localAddForcePos = transform.InverseTransformPoint(_hitInfo.point); localAddForcePos.x = 0f ; localAddForcePos.z = 0f ; _contactPosition = transform.TransformPoint(localAddForcePos); var localAngleY = _steerAngle + _toeAngle; var forward = Quaternion.AngleAxis(localAngleY, transform.up) * transform.forward; var right = Quaternion.AngleAxis(localAngleY, transform.up) * transform.right; _forwardDirection = Vector3.ProjectOnPlane(forward, _hitInfo.normal).normalized; _sidewaysDirection = Vector3.ProjectOnPlane(right, _hitInfo.normal).normalized; _currentCamberAngle = Vector3.SignedAngle(_hitInfo.normal, transform.up, forward) + _camberAngle; var vel = _rigidbody.GetPointVelocity(_hitInfo.point); if (_hitInfo.collider.attachedRigidbody != null ) { var contactVel = _hitInfo.collider.attachedRigidbody.GetPointVelocity(_hitInfo.point); vel -= contactVel; } _forwardVelocity = Vector3.Dot(vel, _forwardDirection); _sidewaysVelocity = Vector3.Dot(vel, _sidewaysDirection); var noiseX = _hitInfo.point.x * _bumpPeriod; var noiseY = _hitInfo.point.z * _bumpPeriod; var bump = (Mathf.PerlinNoise(noiseX, noiseY) - 0.5f ) * _bumpAmplitude; _suspensionCompression = _suspensionLength - (_hitInfo.distance + bump - _radius); }
更新接触位置
1 2 3 4 var localAddForcePos = transform.InverseTransformPoint(_hitInfo.point); localAddForcePos.x = 0f ; localAddForcePos.z = 0f ; _contactPosition = transform.TransformPoint(localAddForcePos);
通过获取并修改碰撞点信息更新得到接触位置 _ContactPosition
。
使用 transform.InverseTransformPoint(_hitInfo.point)
将碰撞点从世界坐标转换为本地坐标。
设置 localAddForcePos.x
和 localAddForcePos.z
为 0f,确保只保留垂直方向上的偏移。
使用 transform.TransformPoint(localAddForcePos)
将修改后的本地坐标再转换回世界坐标,得到最终的接触位置。
计算前向和侧向方向向量
1 2 3 4 5 var localAngleY = _steerAngle + _toeAngle;var forward = Quaternion.AngleAxis(localAngleY, transform.up) * transform.forward;var right = Quaternion.AngleAxis(localAngleY, transform.up) * transform.right;_forwardDirection = Vector3.ProjectOnPlane(forward, _hitInfo.normal).normalized; _sidewaysDirection = Vector3.ProjectOnPlane(right, _hitInfo.normal).normalized;
计算并更新前向方向向量 _forwardDirection
和侧向方向向量 _sidewaysDirection
。
定义变量 localAngleY
并设为转向角度和外倾角的总和,在这里称为总转向角度。
通过 Quaternion.AngleAxis(localAngleY, transform.up)
创建一个绕 Y 轴 (transform.up) 旋转的四元数,表示进行总转向后的方向,将其与原始的前向 transform.forward
相乘,即四元数相乘运算,也就是将总转向角度加在正前方向上,表示从前向转向后的角度,将其定义为新的前向角度。
同样,将进行总转向后的方向与原始的右向 transform.right
相乘,将总转向角度加在正右侧方向上,表示从原始正右侧转向后的角度,将其定义为新的右侧角度。
使用 Vector3.ProjectOnPlane
方法将前向和侧向向量分别投影到与碰撞法线垂直的平面上,并进行归一化,以确保它们正确地反映相对于地面的方向,同时避免因为向量长度不一致导致的计算误差,然后分别赋给_forwardDirection
和_sidewaysDirection
。
更新当前倾角
1 _currentCamberAngle = Vector3.SignedAngle(_hitInfo.normal, transform.up, forward) + _camberAngle;
更新当前的倾角 _currentCamberAngle
。
Vector3.SignedAngle(0A, OB, forward)
方法可以理解为得到“两个向量之间的夹角(有符号的)”,或者理解为“将两个向量都放在坐标原点,一个向量要向哪个方向旋转多少度 才能与另一个向量重合。”,通过输入向量 OA 和 OB 以及旋转轴 forward 也就是 Z 轴正向,可以得到向量之间的差角,并且该差角永远是两个差角之间较小的那一个,其值永远在 -180° ~ 180° 之间。
这里通过 Vector3.SignedAngle(_hitInfo.normal, transform.up, forward)
得到触地点法向方向(也就是该处地面的法线方向)到 WheelCollider2 所处物体的正上方向(也就是载具视角的正上方向)之间绕 Z 轴正向之间的夹角,将计算出来的夹角加上初始设定的 CamberAngle 即得到实际的倾角。
计算速度分量
1 2 3 4 5 6 7 8 9 10 var vel = _rigidbody.GetPointVelocity(_hitInfo.point);if (_hitInfo.collider.attachedRigidbody != null ){ var contactVel = _hitInfo.collider.attachedRigidbody.GetPointVelocity(_hitInfo.point); vel -= contactVel; } _forwardVelocity = Vector3.Dot(vel, _forwardDirection); _sidewaysVelocity = Vector3.Dot(vel, _sidewaysDirection);
计算并更新前向速度 _forwardVelocity
和侧向速度 _sidewaysVelocity
。
通过 GetPointVelocity
方法获取到车轮刚体组件与地面碰撞点的速度并赋给变量 vel
。
如果车轮碰撞到的物体也存在刚体组件,则通过碰撞到的物体的 GetPointVelocity
方法获取被碰撞物体在同一点的速度,两者相减得到车轮的相对速度。
将得到的相对速度与前向和侧向两个单位向量进行 Vector3.Dot
点乘方法来分解速度,并分别得到前向分速度和侧向分速度。