Compare commits

..

2 Commits

Author SHA1 Message Date
ab8cc70785 assets and etc. 2025-11-08 17:05:04 +07:00
ae5cd2c8b6 prerelease 2025-11-06 12:13:30 +07:00
48 changed files with 3726 additions and 1418 deletions

View File

@ -18,7 +18,7 @@
"__type": "Sandbox.ParticleEffect", "__type": "Sandbox.ParticleEffect",
"__guid": "4e3d505f-af05-4c8f-85ee-c5a264ff23b6", "__guid": "4e3d505f-af05-4c8f-85ee-c5a264ff23b6",
"__enabled": true, "__enabled": true,
"__version": 1, "__version": 3,
"Alpha": { "Alpha": {
"Type": "Curve", "Type": "Curve",
"Evaluation": "Life", "Evaluation": "Life",
@ -46,8 +46,7 @@
"out": 0, "out": 0,
"mode": "Mirrored" "mode": "Mirrored"
} }
], ]
"Constants": "1,0,0,0"
}, },
"ApplyAlpha": false, "ApplyAlpha": false,
"ApplyColor": true, "ApplyColor": true,
@ -63,6 +62,11 @@
"CollisionPrefabChance": 1, "CollisionPrefabChance": 1,
"CollisionPrefabRotation": 0, "CollisionPrefabRotation": 0,
"CollisionRadius": 1, "CollisionRadius": 1,
"ConstantMovement": {
"X": 0,
"Y": 0,
"Z": 0
},
"Damping": 0, "Damping": 0,
"DieOnCollisionChance": 0, "DieOnCollisionChance": 0,
"FollowerPrefab": [], "FollowerPrefab": [],
@ -124,29 +128,17 @@
"ConstantA": "1,1,1,1", "ConstantA": "1,1,1,1",
"ConstantB": "1,0,0,1" "ConstantB": "1,0,0,1"
}, },
"InitialVelocity": {
"X": 0,
"Y": 0,
"Z": 0
},
"Lifetime": { "Lifetime": {
"Type": "Range", "Type": "Range",
"Evaluation": "Particle", "Evaluation": "Seed",
"CurveA": [
{
"x": 0.5,
"y": 0.5,
"in": 0,
"out": 0,
"mode": "Mirrored"
}
],
"CurveB": [
{
"x": 0.5,
"y": 0.5,
"in": 0,
"out": 0,
"mode": "Mirrored"
}
],
"Constants": "0.1,2,0,0" "Constants": "0.1,2,0,0"
}, },
"LocalSpace": 0,
"MaxParticles": 500, "MaxParticles": 500,
"OnComponentDestroy": null, "OnComponentDestroy": null,
"OnComponentDisabled": null, "OnComponentDisabled": null,
@ -166,29 +158,7 @@
"Pitch": 0, "Pitch": 0,
"PreWarm": 0, "PreWarm": 0,
"PushStrength": 0, "PushStrength": 0,
"Roll": { "Roll": 0,
"Type": "Range",
"Evaluation": "Particle",
"CurveA": [
{
"x": 0.5,
"y": 0.5,
"in": 0,
"out": 0,
"mode": "Mirrored"
}
],
"CurveB": [
{
"x": 0.5,
"y": 0.5,
"in": 0,
"out": 0,
"mode": "Mirrored"
}
],
"Constants": "0,360,0,0"
},
"Scale": { "Scale": {
"Type": "Curve", "Type": "Curve",
"Evaluation": "Life", "Evaluation": "Life",
@ -230,36 +200,16 @@
"out": 0, "out": 0,
"mode": "Mirrored" "mode": "Mirrored"
} }
], ]
"Constants": "1,0,0,0"
}, },
"SequenceId": 0, "SequenceId": 0,
"SequenceSpeed": 1, "SequenceSpeed": 1,
"SequenceTime": 1, "SequenceTime": 1,
"SheetSequence": false, "SheetSequence": false,
"Space": "World",
"StartDelay": 0, "StartDelay": 0,
"StartVelocity": { "StartVelocity": {
"Type": "Range", "Type": "Range",
"Evaluation": "Particle", "Evaluation": "Seed",
"CurveA": [
{
"x": 0.5,
"y": 0.5,
"in": 0,
"out": 0,
"mode": "Mirrored"
}
],
"CurveB": [
{
"x": 0.5,
"y": 0.5,
"in": 0,
"out": 0,
"mode": "Mirrored"
}
],
"Constants": "10,70,0,0" "Constants": "10,70,0,0"
}, },
"Stretch": 0, "Stretch": 0,
@ -267,7 +217,11 @@
"Timing": "GameTime", "Timing": "GameTime",
"Tint": "1,1,1,1", "Tint": "1,1,1,1",
"UsePrefabFeature": false, "UsePrefabFeature": false,
"Yaw": 0 "Yaw": {
"Type": "Range",
"Evaluation": "Seed",
"Constants": "0,360,0,0"
}
}, },
{ {
"__type": "Sandbox.ParticleSpriteRenderer", "__type": "Sandbox.ParticleSpriteRenderer",
@ -302,7 +256,8 @@
"Scale": 1, "Scale": 1,
"Shadows": false, "Shadows": false,
"SortMode": "Unsorted", "SortMode": "Unsorted",
"Texture": "materials/particles/shapes/spark2.vtex" "Texture": "textures/shapes/spark3.vtex",
"TextureFilter": "Bilinear"
}, },
{ {
"__type": "Sandbox.ParticleConeEmitter", "__type": "Sandbox.ParticleConeEmitter",

View File

@ -0,0 +1,20 @@
{
"Pacejka": {
"Lateral": {
"B": 16,
"C": 1.5100001,
"D": 2,
"E": -0.8
},
"Longitudinal": {
"B": 16,
"C": 1.3,
"D": 1.2,
"E": 0.5
}
},
"RollResistanceLin": 0.001,
"RollResistanceQuad": 1E-06,
"__references": [],
"__version": 0
}

View File

@ -1,20 +1,8 @@
{ {
"Pacejka": { "B": 8.36,
"Lateral": {
"B": 12,
"C": 1.3,
"D": 1.8,
"E": -1.8
},
"Longitudinal": {
"B": 10.86,
"C": 2.15, "C": 2.15,
"D": 2, "D": 0.833,
"E": 0.992 "E": 0.992,
}
},
"RollResistanceLin": 0.001,
"RollResistanceQuad": 1E-06,
"__references": [], "__references": [],
"__version": 0 "__version": 0
} }

View File

@ -0,0 +1,295 @@
{
"RootObject": {
"__guid": "67db5230-4e08-4970-ab3c-df73f37dd486",
"__version": 1,
"Flags": 0,
"Name": "tire_smoke",
"Position": "0,0,0",
"Rotation": "0,0,0,1",
"Scale": "1,1,1",
"Tags": "",
"Enabled": true,
"NetworkMode": 2,
"NetworkInterpolation": true,
"NetworkOrphaned": 0,
"OwnerTransfer": 1,
"Components": [],
"Children": [
{
"__guid": "83d2bd2a-7d1d-466c-a1a7-cb4771a95da5",
"__version": 1,
"Flags": 0,
"Name": "Smoke",
"Position": "0,0,0",
"Rotation": "0,0,0,1",
"Scale": "1,1,1",
"Tags": "particles",
"Enabled": true,
"NetworkMode": 2,
"NetworkInterpolation": true,
"NetworkOrphaned": 0,
"OwnerTransfer": 1,
"Components": [
{
"__type": "Sandbox.ParticleEffect",
"__guid": "6db5eadc-45c0-482f-8da3-f29b79b84cfe",
"__enabled": true,
"__version": 3,
"Alpha": {
"Type": "Curve",
"Evaluation": "Life",
"CurveA": [
{
"x": 0,
"y": 0,
"in": -4.0000024,
"out": 4.0000024,
"mode": "Mirrored"
},
{
"x": 0.20812808,
"y": 0.475,
"in": 0,
"out": 0,
"mode": "Mirrored"
},
{
"x": 1,
"y": 0,
"in": 0,
"out": 0,
"mode": "Mirrored"
}
],
"CurveB": [
{
"x": 0.5,
"y": 0.5,
"in": 0,
"out": 0,
"mode": "Mirrored"
}
]
},
"ApplyAlpha": true,
"ApplyColor": true,
"ApplyRotation": true,
"ApplyShape": true,
"Bounce": 1,
"Brightness": 1,
"Bumpiness": 0,
"Collision": false,
"CollisionIgnore": null,
"CollisionPrefab": null,
"CollisionPrefabAlign": false,
"CollisionPrefabChance": 1,
"CollisionPrefabRotation": 0,
"CollisionRadius": 1,
"ConstantMovement": {
"X": 0,
"Y": 0,
"Z": 0
},
"Damping": 5,
"DieOnCollisionChance": 0,
"FollowerPrefab": null,
"FollowerPrefabChance": 1,
"FollowerPrefabKill": true,
"Force": true,
"ForceDirection": "0,0,220",
"ForceScale": 1,
"ForceSpace": "World",
"Friction": 1,
"Gradient": {
"Type": "Range",
"Evaluation": "Life",
"GradientA": {
"blend": "Linear",
"color": [
{
"t": 0.5,
"c": "1,1,1,1"
}
],
"alpha": []
},
"GradientB": {
"blend": "Linear",
"color": [
{
"t": 0.5,
"c": "1,1,1,1"
}
],
"alpha": []
},
"ConstantA": "0.18317,0.18317,0.18317,1",
"ConstantB": "1,1,1,1"
},
"InitialVelocity": {
"X": 0,
"Y": 0,
"Z": 0
},
"Lifetime": {
"Type": "Range",
"Evaluation": "Seed",
"Constants": "2,3,0,0"
},
"LocalSpace": 0,
"MaxParticles": 25,
"OnComponentDestroy": null,
"OnComponentDisabled": null,
"OnComponentEnabled": null,
"OnComponentFixedUpdate": null,
"OnComponentStart": null,
"OnComponentUpdate": null,
"OnParticleCreated": null,
"OnParticleDestroyed": null,
"OrbitalForce": {
"X": 0,
"Y": 0,
"Z": 0
},
"OrbitalPull": 0,
"PerParticleTimeScale": 1,
"Pitch": 0,
"PreWarm": 0,
"PushStrength": 0,
"Roll": 0,
"Scale": {
"Type": "Range",
"Evaluation": "Seed",
"Constants": "50,60,0,0"
},
"SequenceId": 0,
"SequenceSpeed": 0.5,
"SequenceTime": 1,
"SheetSequence": true,
"StartDelay": 0.025,
"StartVelocity": {
"Type": "Range",
"Evaluation": "Seed",
"Constants": "10,70,0,0"
},
"Stretch": 0,
"TimeScale": 1,
"Timing": "GameTime",
"Tint": "1,1,1,1",
"UsePrefabFeature": false,
"Yaw": {
"Type": "Range",
"Evaluation": "Seed",
"Constants": "0,360,0,0"
}
},
{
"__type": "Sandbox.ParticleSpriteRenderer",
"__guid": "efbb8b5c-15b8-4e3f-ab55-9e7483b1c1f4",
"__enabled": true,
"__version": 2,
"Additive": false,
"Alignment": "LookAtCamera",
"BlurAmount": 0.5,
"BlurOpacity": 0.91,
"BlurSpacing": 0.73,
"DepthFeather": 0,
"FaceVelocity": false,
"FogStrength": 1,
"LeadingTrail": true,
"Lighting": false,
"MotionBlur": false,
"OnComponentDestroy": null,
"OnComponentDisabled": null,
"OnComponentEnabled": null,
"OnComponentFixedUpdate": null,
"OnComponentStart": null,
"OnComponentUpdate": null,
"Opaque": false,
"RenderOptions": {
"GameLayer": true,
"OverlayLayer": false,
"BloomLayer": false,
"AfterUILayer": false
},
"RotationOffset": 0,
"Scale": 1,
"Shadows": true,
"SortMode": "Unsorted",
"Sprite": {
"$compiler": "embed",
"$source": null,
"data": {
"Animations": [
{
"Name": "Default",
"FrameRate": 15,
"Origin": "0.5,0.5",
"LoopMode": "Loop",
"Frames": [
{
"Texture": "textures/smoketexturesheet.vtex"
}
]
}
],
"__references": null
},
"compiled": null
},
"StartingAnimationName": "Default",
"TextureFilter": "Bilinear"
},
{
"__type": "Sandbox.ParticleSphereEmitter",
"__guid": "4c00fdfa-a1eb-4ded-a6b2-fdf448823a6a",
"__enabled": true,
"Burst": 0,
"Delay": 0,
"DestroyOnEnd": false,
"Duration": 5,
"Loop": true,
"OnComponentDestroy": null,
"OnComponentDisabled": null,
"OnComponentEnabled": null,
"OnComponentFixedUpdate": null,
"OnComponentStart": null,
"OnComponentUpdate": null,
"OnEdge": false,
"Radius": 23.6,
"Rate": 5,
"RateOverDistance": 0,
"Velocity": 0
}
],
"Children": []
}
],
"__properties": {
"NetworkInterpolation": true,
"TimeScale": 1,
"WantsSystemScene": true,
"Metadata": {},
"NavMesh": {
"Enabled": false,
"IncludeStaticBodies": true,
"IncludeKeyframedBodies": true,
"EditorAutoUpdate": true,
"AgentHeight": 64,
"AgentRadius": 16,
"AgentStepSize": 18,
"AgentMaxSlope": 40,
"ExcludedBodies": "",
"IncludedBodies": ""
}
},
"__variables": []
},
"ResourceVersion": 2,
"ShowInMenu": false,
"MenuPath": null,
"MenuIcon": null,
"DontBreakAsTemplate": false,
"__references": [],
"__version": 2
}

BIN
Assets/sounds/tire/skid.wav Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

View File

@ -0,0 +1,18 @@
{
"Images": null,
"Sequences": [
{
"Source": "textures/smoketexturesheet.png",
"IsLooping": true,
"FlipBook": true,
"Columns": 6,
"Rows": 5,
"Frames": 30
}
],
"InputColorSpace": "Linear",
"OutputFormat": "DXT5",
"OutputColorSpace": "Linear",
"OutputMipAlgorithm": "None",
"OutputTypeString": "2D"
}

View File

@ -1,49 +1,213 @@
using Sandbox; using System;
using System; using System.Collections.Generic;
using Sandbox;
namespace VeloX.Powertrain; namespace VeloX;
public class Clutch : PowertrainComponent public partial class Clutch : PowertrainComponent
{ {
[Property] public override float Inertia { get; set; } = 0.002f; protected override void OnAwake()
[Property] public float SlipTorque { get; set; } = 1000f; {
base.OnAwake();
Name ??= "Clutch";
}
public float Pressing { get; set; } = 1; // todo /// <summary>
/// RPM at which automatic clutch will try to engage.
/// </summary>
[Property] public float EngagementRPM { get; set; } = 1200f;
public float ThrottleEngagementOffsetRPM = 400f;
/// <summary>
/// Clutch engagement in range [0,1] where 1 is fully engaged clutch.
/// Affected by Slip Torque field as the clutch can transfer [clutchEngagement * SlipTorque] Nm
/// meaning that higher value of SlipTorque will result in more sensitive clutch.
/// </summary>
[Range( 0, 1 ), Property] public float ClutchInput { get; set; }
/// <summary>
/// Curve representing pedal travel vs. clutch engagement. Should start at 0,0 and end at 1,1.
/// </summary>
[Property] public Curve EngagementCurve { get; set; } = new( new List<Curve.Frame>() { new( 0, 0 ), new( 1, 1 ) } );
public enum ClutchControlType
{
Automatic,
Manual
}
[Property] public ClutchControlType СontrolType { get; set; } = ClutchControlType.Automatic;
/// <summary>
/// The RPM range in which the clutch will go from disengaged to engaged and vice versa.
/// E.g. if set to 400 and engagementRPM is 1000, 1000 will mean clutch is fully disengaged and
/// 1400 fully engaged. Setting it too low might cause clutch to hunt/oscillate.
/// </summary>
[Property] public float EngagementRange { get; set; } = 400f;
/// <summary>
/// Torque at which the clutch will slip / maximum torque that the clutch can transfer.
/// This value also affects clutch engagement as higher slip value will result in clutch
/// that grabs higher up / sooner. Too high slip torque value combined with low inertia of
/// powertrain components might cause instability in powertrain solver.
/// </summary>
[Property] public float SlipTorque { get; set; } = 500f;
/// <summary>
/// Amount of torque that will be passed through clutch even when completely disengaged
/// to emulate torque converter creep on automatic transmissions.
/// Should be higher than rolling resistance of the wheels to get the vehicle rolling.
/// </summary>
[Range( 0, 100f ), Property] public float CreepTorque { get; set; } = 0;
[Property] public float CreepSpeedLimit { get; set; } = 1f;
/// <summary>
/// Clutch engagement based on ClutchInput and the clutchEngagementCurve
/// </summary>
[Property, ReadOnly]
public float Engagement => _clutchEngagement;
private float _clutchEngagement;
protected override void OnStart()
{
base.OnStart();
SlipTorque = Controller.Engine.EstimatedPeakTorque * 1.5f;
}
public override float QueryAngularVelocity( float angularVelocity, float dt )
{
InputAngularVelocity = angularVelocity;
// Return input angular velocity if InputNameHash or OutputNameHash is 0
if ( InputNameHash == 0 || OutputNameHash == 0 )
{
return InputAngularVelocity;
}
// Adjust clutch engagement based on conditions
if ( СontrolType == ClutchControlType.Automatic )
{
Engine engine = Controller.Engine;
// Override engagement when shifting to smoothly engage and disengage gears
if ( Controller.Transmission.IsShifting )
{
float shiftProgress = Controller.Transmission.ShiftProgress;
ClutchInput = MathF.Abs( MathF.Cos( MathF.PI * shiftProgress ) );
}
// Clutch engagement calculation for automatic clutch
else
{
// Calculate engagement
// Engage the clutch if the input spinning faster than the output, but also if vice versa.
float throttleInput = Controller.SwappedThrottle;
float finalEngagementRPM = 500 + ThrottleEngagementOffsetRPM * (throttleInput * throttleInput);
float referenceRPM = MathF.Max( InputRPM, OutputRPM );
ClutchInput = (referenceRPM - finalEngagementRPM) / EngagementRange;
ClutchInput = Math.Clamp( ClutchInput, 0f, 1f );
// Avoid disconnecting clutch at high speed
if ( engine.OutputRPM > engine.IdleRPM * 1.1f && Controller.TotalSpeed > 3f )
{
ClutchInput = 1f;
}
if ( Controller.SwappedBrakes > 0 )
{
ClutchInput = 0;
}
}
if ( Controller.IsClutching > 0 )
{
ClutchInput = 1 - Controller.IsClutching;
}
}
else if ( СontrolType == ClutchControlType.Manual )
{
// Manual clutch engagement through user input
ClutchInput = Controller.IsClutching;
}
OutputAngularVelocity = InputAngularVelocity * _clutchEngagement;
float Wout = Output.QueryAngularVelocity( OutputAngularVelocity, dt ) * _clutchEngagement;
float Win = angularVelocity * (1f - _clutchEngagement);
return Wout + Win;
}
public override float QueryInertia() public override float QueryInertia()
{ {
if ( !HasOutput ) if ( OutputNameHash == 0 )
{
return Inertia; return Inertia;
return Inertia + Output.QueryInertia() * Pressing;
} }
public override float QueryAngularVelocity( float angularVelocity ) float I = Inertia + Output.QueryInertia() * _clutchEngagement;
{ return I;
this.angularVelocity = angularVelocity;
if ( !HasOutput )
return angularVelocity;
float outputW = Output.QueryAngularVelocity( angularVelocity ) * Pressing;
float inputW = angularVelocity * (1 - Pressing);
return outputW + inputW;
} }
public override float ForwardStep( float torque, float inertia ) public override float ForwardStep( float torque, float inertiaSum, float dt )
{ {
if ( !HasOutput )
InputTorque = torque;
InputInertia = inertiaSum;
if ( OutputNameHash == 0 )
return torque; return torque;
Torque = Math.Clamp( torque, -SlipTorque, SlipTorque );
Torque = torque * (1 - (1 - MathF.Pow( Pressing, 0.3f ))); // Get the clutch engagement point from the input value
// Do not use the clutchEnagement directly for any calculations!
_clutchEngagement = EngagementCurve.Evaluate( ClutchInput );
_clutchEngagement = Math.Clamp( _clutchEngagement, 0, 1 );
// Calculate output inertia and torque based on the clutch engagement
float returnTorque = Output.ForwardStep( Torque, inertia * Pressing + Inertia ) * Pressing; // Assume half of the inertia is on the input plate and the other half is on the output clutch plate.
float halfClutchInertia = Inertia * 0.5f;
OutputInertia = (inertiaSum + halfClutchInertia) * _clutchEngagement + halfClutchInertia;
return Math.Clamp( returnTorque, -SlipTorque, SlipTorque ); // Allow the torque output to be only up to the slip torque valu
float outputTorqueClamp = SlipTorque * _clutchEngagement;
OutputTorque = InputTorque;
OutputTorque = Math.Clamp( OutputTorque, 0, outputTorqueClamp );
float slipOverflowTorque = -Math.Min( outputTorqueClamp - OutputTorque, 0 );
// Apply the creep torque commonly caused by torque converter drag in automatic transmissions
//ApplyCreepTorque( ref OutputTorque, CreepTorque );
// Send the torque downstream
float returnTorque = _output.ForwardStep( OutputTorque, OutputInertia, dt ) * _clutchEngagement;
// Clamp the return torque to the slip torque of the clutch once again
//returnTorque = Math.Clamp( returnTorque, -SlipTorque, SlipTorque );
// Torque returned to the input is a combination of torque returned by the powertrain and the torque that
// was possibly never sent downstream
return returnTorque + slipOverflowTorque;
} }
private void ApplyCreepTorque( ref float torque, float creepTorque )
{
// Apply creep torque to forward torque
if ( creepTorque != 0 && Controller.Engine.IsRunning && Controller.TotalSpeed < CreepSpeedLimit )
{
bool torqueWithinCreepRange = torque < creepTorque && torque > -creepTorque;
if ( torqueWithinCreepRange )
{
torque = creepTorque;
}
}
}
} }

View File

@ -0,0 +1,234 @@
using Sandbox;
using System;
namespace VeloX;
public partial class Differential : PowertrainComponent
{
/// <param name="T">Input torque</param>
/// <param name="Wa">Angular velocity of the outputA</param>
/// <param name="Wb">Angular velocity of the outputB</param>
/// <param name="Ia">Inertia of the outputA</param>
/// <param name="Ib">Inertia of the outputB</param>
/// <param name="dt">Time step</param>
/// <param name="biasAB">Torque bias between outputA and outputB. 0 = all torque goes to A, 1 = all torque goes to B</param>
/// <param name="stiffness">Stiffness of the limited slip or locked differential 0-1</param>
/// <param name="powerRamp">Stiffness under power</param>
/// <param name="coastRamp">Stiffness under braking</param>
/// <param name="slipTorque">Slip torque of the limited slip differential</param>
/// <param name="Ta">Torque output towards outputA</param>
/// <param name="Tb">Torque output towards outputB</param>
public delegate void SplitTorque( float T, float Wa, float Wb, float Ia, float Ib, float dt, float biasAB,
float stiffness, float powerRamp, float coastRamp, float slipTorque, out float Ta, out float Tb );
protected override void OnAwake()
{
base.OnAwake();
Name ??= "Differential";
AssignDifferentialDelegate();
}
public enum DifferentialType
{
Open,
Locked,
LimitedSlip,
}
/// <summary>
/// Differential type.
/// </summary>
[Property]
public DifferentialType Type
{
get => _differentialType;
set
{
_differentialType = value;
AssignDifferentialDelegate();
}
}
private DifferentialType _differentialType;
/// <summary>
/// Torque bias between left (A) and right (B) output in [0,1] range.
/// </summary>
[Property, Range( 0, 1 )] public float BiasAB { get; set; } = 0.5f;
/// <summary>
/// Stiffness of locking differential [0,1]. Higher value
/// will result in lower difference in rotational velocity between left and right wheel.
/// Too high value might introduce slight oscillation due to drivetrain windup and a vehicle that is hard to steer.
/// </summary>
[Property, Range( 0, 1 ), HideIf( nameof( _differentialType ), DifferentialType.Open )] public float Stiffness { get; set; } = 0.5f;
/// <summary>
/// Stiffness of the LSD differential under acceleration.
/// </summary>
[Property, Range( 0, 1 ), ShowIf( nameof( _differentialType ), DifferentialType.LimitedSlip )] public float PowerRamp { get; set; } = 1f;
/// <summary>
/// Stiffness of the LSD differential under braking.
/// </summary>
[Property, Range( 0, 1 ), ShowIf( nameof( _differentialType ), DifferentialType.LimitedSlip )] public float CoastRamp { get; set; } = 0.5f;
/// <summary>
/// Second output of differential.
/// </summary>
[Property]
public PowertrainComponent OutputB
{
get { return _outputB; }
set
{
if ( value == this )
{
Log.Warning( $"{Name}: PowertrainComponent Output can not be self." );
OutputBNameHash = 0;
_output = null;
return;
}
if ( _outputB != null )
{
_outputB.InputNameHash = 0;
_outputB.Input = null;
}
_outputB = value;
if ( _outputB != null )
{
_outputB.Input = this;
OutputBNameHash = _outputB.ToString().GetHashCode();
}
else
{
OutputBNameHash = 0;
}
}
}
protected PowertrainComponent _outputB;
public int OutputBNameHash;
/// <summary>
/// Slip torque of limited slip differentials.
/// </summary>
[Property, ShowIf( nameof( _differentialType ), DifferentialType.LimitedSlip )] public float SlipTorque { get; set; } = 400f;
/// <summary>
/// Function delegate that will be used to split the torque between output(A) and outputB.
/// </summary>
public SplitTorque SplitTorqueDelegate;
private void AssignDifferentialDelegate()
{
SplitTorqueDelegate = _differentialType switch
{
DifferentialType.Open => OpenDiffTorqueSplit,
DifferentialType.Locked => LockingDiffTorqueSplit,
DifferentialType.LimitedSlip => LimitedDiffTorqueSplit,
_ => OpenDiffTorqueSplit,
};
}
public static void OpenDiffTorqueSplit( float T, float Wa, float Wb, float Ia, float Ib, float dt, float biasAB,
float stiffness, float powerRamp, float coastRamp, float slipTorque, out float Ta, out float Tb )
{
Ta = T * (1f - biasAB);
Tb = T * biasAB;
}
public static void LockingDiffTorqueSplit( float T, float Wa, float Wb, float Ia, float Ib, float dt, float biasAB,
float stiffness, float powerRamp, float coastRamp, float slipTorque, out float Ta, out float Tb )
{
Ta = T * (1f - biasAB);
Tb = T * biasAB;
float syncTorque = (Wa - Wb) * stiffness * (Ia + Ib) * 0.5f / dt;
Ta -= syncTorque;
Tb += syncTorque;
}
public static void LimitedDiffTorqueSplit( float T, float Wa, float Wb, float Ia, float Ib, float dt, float biasAB,
float stiffness, float powerRamp, float coastRamp, float slipTorque, out float Ta, out float Tb )
{
if ( Wa < 0 || Wb < 0 )
{
Ta = T * (1f - biasAB);
Tb = T * biasAB;
return;
}
// Минимальный момент трения, даже если разницы скоростей нет
float preloadTorque = MathF.Abs( T ) * 0.5f;
float speedDiff = Wa - Wb;
float ramp = T > 0 ? powerRamp : coastRamp;
// Основной момент трения LSD (зависит от разницы скоростей и preload)
float frictionTorque = ramp * (slipTorque * MathF.Abs( speedDiff ) + preloadTorque);
frictionTorque = MathF.Min( frictionTorque, MathF.Abs( T ) * 0.5f );
Ta = T * (1f - biasAB) - MathF.Sign( speedDiff ) * frictionTorque;
Tb = T * biasAB + MathF.Sign( speedDiff ) * frictionTorque;
}
public override float QueryAngularVelocity( float angularVelocity, float dt )
{
InputAngularVelocity = angularVelocity;
if ( OutputNameHash == 0 || OutputBNameHash == 0 )
return angularVelocity;
OutputAngularVelocity = InputAngularVelocity;
float Wa = _output.QueryAngularVelocity( OutputAngularVelocity, dt );
float Wb = _outputB.QueryAngularVelocity( OutputAngularVelocity, dt );
return (Wa + Wb) * 0.5f;
}
public override float QueryInertia()
{
if ( OutputNameHash == 0 || OutputBNameHash == 0 )
return Inertia;
float Ia = _output.QueryInertia();
float Ib = _outputB.QueryInertia();
float I = Inertia + (Ia + Ib);
return I;
}
public override float ForwardStep( float torque, float inertiaSum, float dt )
{
InputTorque = torque;
InputInertia = inertiaSum;
if ( OutputNameHash == 0 || OutputBNameHash == 0 )
return torque;
float Wa = _output.QueryAngularVelocity( OutputAngularVelocity, dt );
float Wb = _outputB.QueryAngularVelocity( OutputAngularVelocity, dt );
float Ia = _output.QueryInertia();
float Ib = _outputB.QueryInertia();
SplitTorqueDelegate.Invoke( torque, Wa, Wb, Ia, Ib, dt, BiasAB, Stiffness, PowerRamp,
CoastRamp, SlipTorque, out float Ta, out float Tb );
float outAInertia = inertiaSum * 0.5f + Ia;
float outBInertia = inertiaSum * 0.5f + Ib;
OutputTorque = Ta + Tb;
OutputInertia = outAInertia + outBInertia;
return _output.ForwardStep( Ta, outAInertia, dt ) + _outputB.ForwardStep( Tb, outBInertia, dt );
}
}

View File

@ -1,79 +0,0 @@
using Sandbox;
using System;
namespace VeloX.Powertrain;
[Category( "VeloX/Powertrain/Differential" )]
public abstract class BaseDifferential : PowertrainComponent
{
[Property] public float FinalDrive { get; set; } = 3.392f;
[Property] public override float Inertia { get; set; } = 0.01f;
//[Property] public float CoastRamp { get; set; } = 1f;
//[Property] public float PowerRamp { get; set; } = 1f;
//[Property] public float Stiffness { get; set; } = 0.1f;
//[Property] public float SlipTorque { get; set; } = 0f;
//[Property] public float SteerLock { get; set; } = 45f;
/// <summary>
/// The PowertrainComponent this component will output to.
/// </summary>
[Property]
public PowertrainComponent OutputB
{
get => _outputb;
set
{
if ( value == this )
{
_outputb = null;
return;
}
_outputb = value;
if ( _outputb != null )
_outputb.Input = this;
}
}
private PowertrainComponent _outputb;
public override bool HasOutput => Output.IsValid() && OutputB.IsValid();
public override float QueryAngularVelocity( float angularVelocity )
{
this.angularVelocity = angularVelocity;
if ( !HasOutput )
return angularVelocity;
float aW = Output.QueryAngularVelocity( angularVelocity );
float bW = OutputB.QueryAngularVelocity( angularVelocity );
return (aW + bW) * FinalDrive * 0.5f;
}
public abstract void SplitTorque( float aW, float bW, float aI, float bI, out float tqA, out float tqB );
public override float ForwardStep( float torque, float inertia )
{
if ( !HasOutput )
return torque;
float aW = Output.QueryAngularVelocity( angularVelocity );
float bW = OutputB.QueryAngularVelocity( angularVelocity );
float aI = Output.QueryInertia();
float bI = OutputB.QueryInertia();
Torque = torque * FinalDrive;
SplitTorque( aW, bW, aI, bI, out float tqA, out float tqB );
tqA = Output.ForwardStep( tqA, inertia * 0.5f * MathF.Pow( FinalDrive, 2 ) + aI );
tqB = OutputB.ForwardStep( tqB, inertia * 0.5f * MathF.Pow( FinalDrive, 2 ) + bI );
return tqA + tqB;
}
}

View File

@ -1,13 +0,0 @@
using Sandbox;
namespace VeloX.Powertrain;
public class OpenDifferential : BaseDifferential
{
[Property] public float BiasAB { get; set; } = 0.5f;
public override void SplitTorque( float aW, float bW, float aI, float bI, out float tqA, out float tqB )
{
tqA = Torque * (1 - BiasAB);
tqB = Torque * BiasAB;
}
}

View File

@ -1,109 +1,663 @@
using Sandbox; using Sandbox;
using Sandbox.Audio;
using System; using System;
using System.Collections.Generic;
using System.Linq;
using VeloX.Utils;
using static Sandbox.VertexLayout;
using static VeloX.EngineStream;
namespace VeloX.Powertrain; namespace VeloX;
public class Engine : PowertrainComponent public class Engine : PowertrainComponent, IScenePhysicsEvents
{ {
[Property, Group( "Settings" )] public float IdleRPM { get; set; } = 900f; protected override void OnAwake()
[Property, Group( "Settings" )] public float MaxRPM { get; set; } = 7000f; {
[Property, Group( "Settings" )] public override float Inertia { get; set; } = 0.151f; base.OnAwake();
[Property, Group( "Settings" )] public float LimiterDuration { get; set; } = 0.05f; Name ??= "Engine";
[Property, Group( "Settings" )] public Curve TorqueMap { get; set; } UpdatePeakPowerAndTorque();
[Property, Group( "Settings" )] public EngineStream Stream { get; set; }
[Sync] public float Throttle { get; internal set; } }
[Property] public bool IsRedlining => !limiterTimer; [Hide] public new bool Input { get; set; }
[Property] public float RPMPercent => Math.Clamp( (RPM - IdleRPM) / (MaxRPM - IdleRPM), 0, 1 );
private float masterThrottle; public delegate float CalculateTorque( float angularVelocity, float dt );
private TimeUntil limiterTimer; /// <summary>
private float finalTorque; /// Delegate for a function that modifies engine power.
/// </summary>
public delegate float PowerModifier();
public enum EngineType
{
ICE,
Electric,
}
/// <summary>
/// If true starter will be ran for [starterRunTime] seconds if engine receives any throttle input.
/// </summary>
[Property] public bool AutoStartOnThrottle { get; set; } = true;
/// <summary>
/// Assign your own delegate to use different type of torque calculation.
/// </summary>
public CalculateTorque CalculateTorqueDelegate;
/// <summary>
/// Engine type. ICE (Internal Combustion Engine) supports features such as starter, stalling, etc.
/// Electric engine (motor) can run in reverse, can not be stalled and does not use starter.
/// </summary>
[Property] public EngineType Type { get; set; } = EngineType.ICE;
/// <summary>
/// Power generated by the engine in kW
/// </summary>
public float generatedPower;
/// <summary>
/// RPM at which idler circuit will try to keep RPMs when there is no input.
/// </summary>
[Property] public float IdleRPM { get; set; } = 900;
/// <summary>
/// Maximum engine power in [kW].
/// </summary>
[Property, Group( "Power" )] public float MaxPower { get; set; } = 120;
/// <summary>
/// Loss power (pumping, friction losses) is calculated as the percentage of maxPower.
/// Should be between 0 and 1 (100%).
/// </summary>
[Range( 0, 1 ), Property] public float EngineLossPercent { get; set; } = 0.8f;
/// <summary>
/// If true the engine will be started immediately, without running the starter, when the vehicle is enabled.
/// Sets engine angular velocity to idle angular velocity.
/// </summary>
[Property] public bool FlyingStartEnabled { get; set; }
[Property] public bool Ignition { get; set; }
/// <summary>
/// Power curve with RPM range [0,1] on the X axis and power coefficient [0,1] on Y axis.
/// Both values are represented as percentages and should be in 0 to 1 range.
/// Power coefficient is multiplied by maxPower to get the final power at given RPM.
/// </summary>
[Property, Group( "Power" )] public Curve PowerCurve { get; set; } = new( new List<Curve.Frame>() { new( 0, 0.4f ), new( 0.16f, 0.65f ), new( 0.32f, 0.85f ), new( 0.64f, 1f ), new( 0.8f, 0.9f ), new( 0.9f, 0.6f ), new( 1f, 0.2f ) } );
/// <summary>
/// Is the engine currently hitting the rev limiter?
/// </summary>
public bool RevLimiterActive;
/// <summary>
/// If engine RPM rises above revLimiterRPM, how long should fuel cutoff last?
/// Higher values make hitting rev limiter more rough and choppy.
/// </summary>
[Property] public float RevLimiterCutoffDuration { get; set; } = 0.12f;
/// <summary>
/// Engine RPM at which rev limiter activates.
/// </summary>
[Property] public float RevLimiterRPM { get; set; } = 6700;
/// <summary>
/// Is the starter currently active?
/// </summary>
[Property, ReadOnly, Group( "Info" )] public bool StarterActive = false;
/// <summary>
/// Torque starter motor can put out. Make sure that this torque is more than loss torque
/// at the starter RPM limit. If too low the engine will fail to start.
/// </summary>
[Property] public float StartDuration = 0.5f;
/// <summary>
/// Peak power as calculated from the power curve. If the power curve peaks at Y=1 peak power will equal max power field value.
/// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public float EstimatedPeakPower => _peakPower;
private float _peakPower;
/// <summary>
/// RPM at which the peak power is achieved.
/// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public float EstimatedPeakPowerRPM => _peakPowerRpm;
private float _peakPowerRpm;
/// <summary>
/// Peak torque value as calculated from the power curve.
/// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public float EstimatedPeakTorque => _peakTorque;
private float _peakTorque;
/// <summary>
/// RPM at which the engine achieves the peak torque, calculated from the power curve.
/// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public float EstimatedPeakTorqueRPM => _peakTorqueRpm;
private float _peakTorqueRpm;
/// <summary>
/// RPM as a percentage of maximum RPM.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public float RPMPercent => _rpmPercent;
[Sync] private float _rpmPercent { get; set; }
/// <summary>
/// Engine throttle position. 0 for no throttle and 1 for full throttle.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public float ThrottlePosition { get; private set; }
/// <summary>
/// Is the engine currently running?
/// Requires ignition to be enabled.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public bool IsRunning { get; private set; }
/// <summary>
/// Is the engine currently running?
/// Requires ignition to be enabled.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public bool IsActive { get; private set; }
private float _idleAngularVelocity;
/// <summary>
/// Current load of the engine, based on the power produced.
/// </summary>
public float Load => _load;
public event Action OnEngineStart;
public event Action OnEngineStop;
public event Action OnRevLimiter;
private float _load;
private EngineStreamPlayer StreamPlayer;
public float[] friction = [15.438f, 2.387f, 0.7958f];
protected override void OnStart() protected override void OnStart()
{ {
base.OnStart(); base.OnStart();
StreamPlayer = new( Stream ); if ( Type == EngineType.ICE )
{
CalculateTorqueDelegate = CalculateTorqueICE;
}
else if ( Type == EngineType.Electric )
{
IdleRPM = 0f;
FlyingStartEnabled = true;
CalculateTorqueDelegate = CalculateTorqueElectric;
StarterActive = false;
StartDuration = 0.001f;
RevLimiterCutoffDuration = 0f;
}
} }
public float GetFrictionTorque( float throttle, float rpm ) public void StartEngine()
{ {
float s = rpm < 0 ? -1f : 1f; if ( IsRunning ) return;
float r = s * rpm * 0.001f;
float f = friction[0] + friction[1] * r + friction[2] * r * r; Ignition = true;
return -s * f * (1 - throttle); OnEngineStart?.Invoke();
if ( Type != EngineType.Electric )
{
if ( FlyingStartEnabled )
{
FlyingStart();
} }
private float GenerateTorque() else if ( !StarterActive && Controller != null )
{ {
float throttle = Throttle; StarterCoroutine();
float rpm = RPM;
float friction = GetFrictionTorque( throttle, rpm );
float maxInitialTorque = TorqueMap.Evaluate( RPMPercent ) - friction;
float idleFadeStart = Math.Clamp( MathX.Remap( rpm, IdleRPM - 300, IdleRPM, 1, 0 ), 0, 1 );
float idleFadeEnd = Math.Clamp( MathX.Remap( rpm, IdleRPM, IdleRPM + 600, 1, 0 ), 0, 1 );
float additionalEnergySupply = idleFadeEnd * (-friction / maxInitialTorque) + idleFadeStart;
if ( rpm > MaxRPM )
{
throttle = 0;
limiterTimer = LimiterDuration;
} }
else if ( !limiterTimer )
throttle = 0;
masterThrottle = Math.Clamp( additionalEnergySupply + throttle, 0, 1 );
float realInitialTorque = maxInitialTorque * masterThrottle;
Torque = realInitialTorque + friction;
return Torque;
} }
public override float ForwardStep( float _, float __ )
{
if ( !HasOutput )
{
angularVelocity += GenerateTorque() / Inertia * Time.Delta;
angularVelocity = Math.Max( angularVelocity, 0 );
return 0;
} }
float outputInertia = Output.QueryInertia(); private async void StarterCoroutine()
float inertiaSum = Inertia + outputInertia;
float outputW = Output.QueryAngularVelocity( angularVelocity );
float targetW = Inertia / inertiaSum * angularVelocity + outputInertia / inertiaSum * outputW;
float generatedTorque = GenerateTorque();
float reactTorque = (targetW - angularVelocity) * Inertia / Time.Delta;
float returnedTorque = Output.ForwardStep( generatedTorque - reactTorque, Inertia );
finalTorque = generatedTorque + reactTorque + returnedTorque;
angularVelocity += finalTorque / inertiaSum * Time.Delta;
angularVelocity = Math.Max( angularVelocity, 0 );
UpdateStream();
return finalTorque;
}
private void UpdateStream()
{ {
if ( StreamPlayer is null ) if ( Type == EngineType.Electric || StarterActive )
return; return;
StreamPlayer.Throttle = Throttle; try
StreamPlayer.RPMPercent = RPMPercent; {
StreamPlayer.EngineState = EngineState.Running; float startTimer = 0f;
StreamPlayer.IsRedlining = IsRedlining; StarterActive = true;
// Ensure safe start duration
StartDuration = Math.Max( 0.1f, StartDuration );
_starterTorque = ((_idleAngularVelocity - OutputAngularVelocity) * Inertia) / StartDuration;
while ( startTimer <= StartDuration && StarterActive )
{
startTimer += 0.1f;
await Task.DelaySeconds( 0.1f );
}
}
finally
{
_starterTorque = 0;
StarterActive = false;
IsActive = true;
}
}
private void FlyingStart()
{
Ignition = true;
StarterActive = false;
OutputAngularVelocity = IdleRPM.RPMToAngularVelocity();
}
public void StopEngine()
{
Ignition = false;
IsActive = true;
OnEngineStop?.Invoke();
}
/// <summary>
/// Toggles engine state.
/// </summary>
public void StartStopEngine()
{
if ( IsRunning )
StopEngine();
else
StartEngine();
}
public void UpdatePeakPowerAndTorque()
{
GetPeakPower( out _peakPower, out _peakPowerRpm );
GetPeakTorque( out _peakTorque, out _peakTorqueRpm );
}
public void UpdateEngine( in float dt )
{
StreamEngineUpdate( dt, WorldPosition );
if ( IsProxy )
return;
// Cache values
_userThrottleInput = _userThrottleInput.LerpTo( Controller.SwappedThrottle, 0.05f );
ThrottlePosition = _userThrottleInput;
_idleAngularVelocity = IdleRPM.RPMToAngularVelocity();
_revLimiterAngularVelocity = RevLimiterRPM.RPMToAngularVelocity();
if ( _revLimiterAngularVelocity == 0f )
return;
// Check for start on throttle
if ( !IsRunning && !StarterActive && AutoStartOnThrottle && ThrottlePosition > 0.2f )
StartEngine();
bool wasRunning = IsRunning;
IsRunning = Ignition;
if ( wasRunning && !IsRunning )
StopEngine();
// Physics update
if ( OutputNameHash == 0 )
return;
float drivetrainInertia = _output.QueryInertia();
float inertiaSum = Inertia + drivetrainInertia;
if ( inertiaSum == 0 )
return;
float drivetrainAngularVelocity = QueryAngularVelocity( OutputAngularVelocity, dt );
float targetAngularVelocity = Inertia / inertiaSum * OutputAngularVelocity + drivetrainInertia / inertiaSum * drivetrainAngularVelocity;
// Calculate generated torque and power
float generatedTorque = CalculateTorqueICE( OutputAngularVelocity, dt );
generatedPower = TorqueToPowerInKW( in OutputAngularVelocity, in generatedTorque );
// Calculate reaction torque
float reactionTorque = (targetAngularVelocity - OutputAngularVelocity) * Inertia / dt;
// Calculate/get torque returned from wheels
OutputTorque = generatedTorque - reactionTorque;
float returnTorque = ForwardStep( OutputTorque, 0, dt );
float totalTorque = generatedTorque + returnTorque + reactionTorque;
OutputAngularVelocity += totalTorque / inertiaSum * dt;
// Clamp the angular velocity to prevent any powertrain instabilities over the limits
OutputAngularVelocity = Math.Clamp( OutputAngularVelocity, 0, _revLimiterAngularVelocity * 1.05f );
// Calculate cached values
_rpmPercent = Math.Clamp( OutputAngularVelocity / _revLimiterAngularVelocity, 0, 1 );
_load = Math.Clamp( generatedPower / MaxPower, 0, 1 );
StreamPlayer.Update( Time.Delta, WorldPosition );
} }
private float _starterTorque;
private float _revLimiterAngularVelocity;
private float _userThrottleInput;
private async void RevLimiter()
{
if ( RevLimiterActive || Type == EngineType.Electric || RevLimiterCutoffDuration == 0 )
return;
RevLimiterActive = true;
OnRevLimiter?.Invoke();
await Task.DelayRealtimeSeconds( RevLimiterCutoffDuration );
RevLimiterActive = false;
}
/// <summary>
/// Calculates torque for electric engine type.
/// </summary>
public float CalculateTorqueElectric( float angularVelocity, float dt )
{
float absAngVel = Math.Abs( angularVelocity );
// Avoid angular velocity spikes while shifting
if ( Controller.Transmission.IsShifting )
ThrottlePosition = 0;
float maxLossPower = MaxPower * 0.3f;
float lossPower = maxLossPower * (1f - ThrottlePosition) * RPMPercent;
float genPower = MaxPower * ThrottlePosition;
float totalPower = genPower - lossPower;
totalPower = MathX.Lerp( totalPower * 0.1f, totalPower, RPMPercent * 10f );
float clampedAngVel = absAngVel < 10f ? 10f : absAngVel;
return PowerInKWToTorque( clampedAngVel, totalPower );
}
/// <summary>
/// Calculates torque for ICE (Internal Combustion Engine).
/// </summary>
public float CalculateTorqueICE( float angularVelocity, float dt )
{
// Set the throttle to 0 when shifting, but avoid doing so around idle RPM to prevent stalls.
if ( Controller.Transmission.IsShifting && angularVelocity > _idleAngularVelocity )
ThrottlePosition = 0f;
// Set throttle to 0 when starter active.
if ( StarterActive )
ThrottlePosition = 0f;
// Apply idle throttle correction to keep the engine running
else
ApplyICEIdleCorrection();
// Trigger rev limiter if needed
if ( angularVelocity >= _revLimiterAngularVelocity && !RevLimiterActive )
RevLimiter();
// Calculate torque
float generatedTorque;
// Do not generate any torque while starter is active to prevent RPM spike during startup
// or while stalled to prevent accidental starts.
if ( StarterActive )
generatedTorque = 0f;
else
generatedTorque = CalculateICEGeneratedTorqueFromPowerCurve();
float lossTorque = (StarterActive || ThrottlePosition > 0.2f) ? 0f : CalculateICELossTorqueFromPowerCurve();
// Reduce the loss torque at rev limiter, but allow it to be >0 to prevent vehicle getting
// stuck at rev limiter.
if ( RevLimiterActive )
lossTorque *= 0.25f;
generatedTorque += _starterTorque + lossTorque;
return generatedTorque;
}
private float CalculateICELossTorqueFromPowerCurve()
{
// Avoid issues with large torque spike around 0 angular velocity.
if ( OutputAngularVelocity < 10f )
return -OutputAngularVelocity * MaxPower * 0.03f;
float angVelPercent = OutputAngularVelocity < 10f ? 0.1f : Math.Clamp( OutputAngularVelocity, 0, _revLimiterAngularVelocity ) / _revLimiterAngularVelocity;
float lossPower = angVelPercent * 3f * -MaxPower * Math.Clamp( _userThrottleInput + 0.5f, 0, 1 ) * EngineLossPercent;
return PowerInKWToTorque( OutputAngularVelocity, lossPower );
}
private void ApplyICEIdleCorrection()
{
if ( Ignition && OutputAngularVelocity < _idleAngularVelocity * 1.1f )
{
// Apply a small correction to account for the error since the throttle is applied only
// if the idle RPM is below the target RPM.
float idleCorrection = _idleAngularVelocity * 1.08f - OutputAngularVelocity;
idleCorrection = idleCorrection < 0f ? 0f : idleCorrection;
float idleThrottlePosition = Math.Clamp( idleCorrection * 0.01f, 0, 1 );
ThrottlePosition = Math.Max( _userThrottleInput, idleThrottlePosition );
}
}
private float CalculateICEGeneratedTorqueFromPowerCurve()
{
generatedPower = 0;
float torque = 0;
if ( !Ignition && !StarterActive )
return 0;
if ( RevLimiterActive )
ThrottlePosition = 0.2f;
else
{
// Add maximum losses to the maximum power when calculating the generated power since the maxPower is net value (after losses).
generatedPower = PowerCurve.Evaluate( _rpmPercent ) * (MaxPower * (1f + EngineLossPercent)) * ThrottlePosition;
torque = PowerInKWToTorque( OutputAngularVelocity, generatedPower );
}
return torque;
}
public void GetPeakTorque( out float peakTorque, out float peakTorqueRpm )
{
peakTorque = 0;
peakTorqueRpm = 0;
for ( float i = 0.05f; i < 1f; i += 0.05f )
{
float rpm = i * RevLimiterRPM;
float P = PowerCurve.Evaluate( i ) * MaxPower;
if ( rpm < IdleRPM )
{
continue;
}
float W = rpm.RPMToAngularVelocity();
float T = (P * 1000f) / W;
if ( T > peakTorque )
{
peakTorque = T;
peakTorqueRpm = rpm;
}
}
}
public void GetPeakPower( out float peakPower, out float peakPowerRpm )
{
float maxY = 0f;
float maxX = 1f;
for ( float i = 0f; i < 1f; i += 0.05f )
{
float y = PowerCurve.Evaluate( i );
if ( y > maxY )
{
maxY = y;
maxX = i;
}
}
peakPower = maxY * MaxPower;
peakPowerRpm = maxX * RevLimiterRPM;
}
[Property, Group( "Parsing" )] string Clipboard { get; set; }
[Button, Group( "Parsing" )]
public void FromLUT()
{
if ( Clipboard == null )
return;
using var undo = Scene.Editor?.UndoScope( "From LUT" ).WithComponentChanges( this ).Push();
var data = new List<(float RPM, float PowerHP)>();
var lines = Clipboard.Split( ['\n', '\r'], StringSplitOptions.RemoveEmptyEntries );
foreach ( var line in lines )
{
var parts = line.Split( '|' );
if ( parts.Length == 2 &&
float.TryParse( parts[0], out float rpm ) &&
float.TryParse( parts[1], out float powerHP ) )
{
data.Add( (rpm, powerHP) );
}
}
RevLimiterRPM = data.Max( x => x.RPM );
MaxPower = data.Max( x => x.PowerHP ) * 0.746f;
var frames = data.Select( d =>
new Curve.Frame(
d.RPM / RevLimiterRPM,
(d.PowerHP * 0.746f) / MaxPower
)
).ToList();
PowerCurve = new( frames );
Clipboard = null;
}
[Property] public EngineStream Stream { get; set; }
public bool EngineSoundPaused => !IsRunning;
private float _wobbleTime;
private static readonly Mixer EngineMixer = Mixer.FindMixerByName( "Car Engine" );
private readonly Dictionary<Layer, SoundHandle> EngineSounds = [];
protected override void OnDestroy()
{
base.OnDestroy();
foreach ( var item in EngineSounds.Values )
item.Dispose();
}
public void StreamEngineUpdate( float deltaTime, Vector3 position, bool isLocal = false )
{
var globalPitch = 1.0f;
// Gear wobble effect
if ( _wobbleTime > 0 )
{
_wobbleTime -= deltaTime * (0.1f + ThrottlePosition);
globalPitch += MathF.Cos( _wobbleTime * Stream.Parameters.WobbleFrequency ) * _wobbleTime * (1 - _wobbleTime) * Stream.Parameters.WobbleStrength;
}
globalPitch *= Stream.Parameters.Pitch;
// Redline effect
var redlineVolume = 1.0f;
if ( RPMPercent >= 0.95f )
{
redlineVolume = 1 - Stream.Parameters.RedlineStrength +
MathF.Cos( Time.Now * Stream.Parameters.RedlineFrequency ) *
Stream.Parameters.RedlineStrength;
}
// Process layers
foreach ( var (id, layer) in Stream.Layers )
{
EngineSounds.TryGetValue( layer, out var channel );
if ( !channel.IsValid() )
{
channel = Sound.PlayFile( layer.AudioPath );
EngineSounds[layer] = channel;
}
if ( channel.Paused && EngineSoundPaused )
continue;
// Reset controller outputs
float layerVolume = 1.0f;
float layerPitch = 1.0f;
// Apply all controllers
foreach ( var controller in layer.Controllers )
{
var inputValue = controller.InputParameter switch
{
Audio.Controller.InputTypes.Throttle => _userThrottleInput,
Audio.Controller.InputTypes.RpmFraction => RPMPercent,
_ => 0.0f
};
var normalized = Math.Clamp( inputValue, controller.InputRange.Min, controller.InputRange.Max );
var outputValue = controller.InputRange.Remap(
normalized,
controller.OutputRange.Min,
controller.OutputRange.Max
);
// Apply to correct parameter
switch ( controller.OutputParameter )
{
case Audio.Controller.OutputTypes.Volume:
layerVolume *= outputValue;
break;
case Audio.Controller.OutputTypes.Pitch:
layerPitch *= outputValue;
break;
}
}
// Apply redline effect if needed
layerVolume *= layer.UseRedline ? redlineVolume : 1.0f;
layerPitch *= globalPitch;
// Update audio channel
channel.Pitch = layerPitch;
channel.Volume = layerVolume * Stream.Parameters.Volume;
channel.Position = position;
channel.ListenLocal = isLocal;
channel.Paused = EngineSoundPaused;
channel.TargetMixer = EngineMixer;
}
}
} }

View File

@ -1,45 +0,0 @@
using Sandbox;
using System;
namespace VeloX.Powertrain;
[Category( "VeloX/Powertrain/Gearbox" )]
public abstract class BaseGearbox : PowertrainComponent
{
[Property] public override float Inertia { get; set; } = 0.01f;
protected float ratio;
public override float QueryInertia()
{
if ( !HasOutput || ratio == 0 )
return Inertia;
return Inertia + Output.QueryInertia() / MathF.Pow( ratio, 2 );
}
public override float QueryAngularVelocity( float angularVelocity )
{
this.angularVelocity = angularVelocity;
if ( !HasOutput || ratio == 0 )
return angularVelocity;
return Output.QueryAngularVelocity( angularVelocity ) * ratio;
}
public override float ForwardStep( float torque, float inertia )
{
Torque = torque * ratio;
if ( !HasOutput )
return torque;
if ( ratio == 0 )
{
Output.ForwardStep( 0, Inertia * 0.5f );
return torque;
}
return Output.ForwardStep( Torque, (inertia + Inertia) * MathF.Pow( ratio, 2 ) ) / ratio;
}
}

View File

@ -1,52 +0,0 @@
using Sandbox;
namespace VeloX.Powertrain;
public class ManualGearbox : BaseGearbox
{
[Property] public float[] Ratios { get; set; } = [3.626f, 2.200f, 1.541f, 1.213f, 1.000f, 0.767f];
[Property] public float Reverse { get; set; } = 3.4f;
[Property, InputAction] public string ForwardAction { get; set; } = "Attack1";
[Property, InputAction] public string BackwardAction { get; set; } = "Attack2";
private int gear;
protected void SetGear( int gear )
{
if ( gear < -1 || gear >= Ratios.Length )
return;
this.gear = gear;
RecalcRatio();
}
private void RecalcRatio()
{
if ( gear == -1 )
ratio = -Reverse;
else if ( gear == 0 )
ratio = 0;
else
ratio = Ratios[gear - 1];
}
public void Shift( int dir )
{
SetGear( gear + dir );
}
private void InputResolve()
{
if ( Sandbox.Input.Pressed( ForwardAction ) )
Shift( 1 );
else if ( Sandbox.Input.Pressed( BackwardAction ) )
Shift( -1 );
}
public override float ForwardStep( float torque, float inertia )
{
InputResolve();
return base.ForwardStep( torque, inertia );
}
}

View File

@ -1,34 +0,0 @@
using Sandbox;
using System;
namespace VeloX.Powertrain;
public class PowerWheel : PowertrainComponent
{
[Property] public VeloXWheel Wheel { get; set; }
public override float QueryInertia()
{
float dtScale = Math.Clamp( Time.Delta, 0.01f, 0.05f ) / 0.005f;
return Wheel.BaseInertia * dtScale;
}
public override float QueryAngularVelocity( float angularVelocity )
{
return Wheel.AngularVelocity;
}
public override float ForwardStep( float torque, float inertia )
{
Wheel.AutoPhysics = false;
Wheel.Torque = torque;
Wheel.Brake = Vehicle.Brake;
Inertia = Wheel.BaseInertia + inertia;
Wheel.Inertia = inertia;
Wheel.DoPhysics( Vehicle );
angularVelocity = Wheel.AngularVelocity;
return Wheel.CounterTorque;
}
}

View File

@ -1,26 +1,31 @@
using Sandbox; using System;
using System; using Sandbox;
namespace VeloX.Powertrain; namespace VeloX;
public abstract class PowertrainComponent : Component public abstract class PowertrainComponent : Component
{ {
protected override void OnAwake() [Property] public VeloXBase Controller;
{ /// <summary>
Vehicle ??= Components.Get<VeloXBase>( FindMode.EverythingInSelfAndAncestors ); /// Name of the component. Only unique names should be used on the same vehicle.
} /// </summary>
[Property] public string Name { get; set; }
public const float RAD_TO_RPM = 60f / MathF.Tau;
public const float RPM_TO_RAD = 1 / (60 / MathF.Tau);
public const float UNITS_PER_METER = 39.37f;
public const float UNITS_TO_METERS = 0.01905f;
public const float KG_TO_N = 9.80665f;
public const float KG_TO_KN = 0.00980665f;
[Property] public VeloXBase Vehicle { get; set; } /// <summary>
[Property] public virtual float Inertia { get; set; } = 0.02f; /// Angular inertia of the component. Higher inertia value will result in a powertrain that is slower to spin up, but
/// also slower to spin down. Too high values will result in (apparent) sluggish response while too low values will
/// result in vehicle being easy to stall and possible powertrain instability / glitches.
/// </summary>
[Property, Range( 0.0002f, 2f )] public float Inertia { get; set; } = 0.05f;
[Property, ReadOnly] public float InputTorque;
[Property, ReadOnly] public float OutputTorque;
public float InputAngularVelocity;
public float OutputAngularVelocity;
public float InputInertia;
public float OutputInertia;
/// <summary> /// <summary>
/// Input component. Set automatically. /// Input component. Set automatically.
@ -34,67 +39,143 @@ public abstract class PowertrainComponent : Component
if ( value == null || value == this ) if ( value == null || value == this )
{ {
_input = null; _input = null;
InputNameHash = 0;
return; return;
} }
_input = value; _input = value;
InputNameHash = _input.GetHashCode();
} }
} }
private PowertrainComponent _input; protected PowertrainComponent _input;
public int InputNameHash; public int InputNameHash;
/// <summary> /// <summary>
/// The PowertrainComponent this component will output to. /// The PowertrainComponent this component will output to.
/// </summary> /// </summary>
[Property] [Property]
public PowertrainComponent Output public PowertrainComponent Output
{ {
get => _output; get { return _output; }
set set
{ {
if ( value == this ) if ( value == this )
{ {
Log.Warning( $"{Name}: PowertrainComponent Output can not be self." );
OutputNameHash = 0;
_output = null; _output = null;
return; return;
} }
_output = value; _output = value;
if ( _output != null ) if ( _output != null )
{
_output.Input = this; _output.Input = this;
OutputNameHash = _output.GetHashCode();
}
else
{
OutputNameHash = 0;
}
} }
} }
private PowertrainComponent _output; protected PowertrainComponent _output;
public int OutputNameHash { get; private set; }
public float RPM => angularVelocity * RAD_TO_RPM;
protected float angularVelocity; /// <summary>
protected float Torque; /// Input shaft RPM of component.
/// </summary>
[Property, ReadOnly]
public float InputRPM => AngularVelocityToRPM( InputAngularVelocity );
public virtual bool HasOutput => Output.IsValid();
/// <summary>
/// Output shaft RPM of component.
/// </summary>
[Property, ReadOnly]
public float OutputRPM => AngularVelocityToRPM( OutputAngularVelocity );
public virtual float QueryAngularVelocity( float angularVelocity, float dt )
{
InputAngularVelocity = angularVelocity;
if ( OutputNameHash == 0 )
{
return angularVelocity;
}
OutputAngularVelocity = angularVelocity;
return _output.QueryAngularVelocity( OutputAngularVelocity, dt );
}
public virtual float QueryInertia() public virtual float QueryInertia()
{ {
if ( !HasOutput ) if ( OutputNameHash == 0 )
return Inertia; return Inertia;
return Inertia + Output.QueryInertia(); float Ii = Inertia;
} float Ia = _output.QueryInertia();
public virtual float QueryAngularVelocity( float angularVelocity ) return Ii + Ia;
}
public virtual float ForwardStep( float torque, float inertiaSum, float dt )
{ {
if ( !HasOutput ) InputTorque = torque;
return angularVelocity; InputInertia = inertiaSum;
return Output.QueryAngularVelocity( angularVelocity ); if ( OutputNameHash == 0 )
return torque;
OutputTorque = InputTorque;
OutputInertia = inertiaSum + Inertia;
return _output.ForwardStep( OutputTorque, OutputInertia, dt );
} }
public virtual float ForwardStep( float torque, float inertia ) public static float TorqueToPowerInKW( in float angularVelocity, in float torque )
{ {
if ( !HasOutput ) // Power (W) = Torque (Nm) * Angular Velocity (rad/s)
return Torque; float powerInWatts = torque * angularVelocity;
return Output.ForwardStep( Torque, inertia + Inertia ); // Convert power from watts to kilowatts
float powerInKW = powerInWatts / 1000f;
return powerInKW;
} }
public static float PowerInKWToTorque( in float angularVelocity, in float powerInKW )
{
// Convert power from kilowatts to watts
float powerInWatts = powerInKW * 1000f;
// Torque (Nm) = Power (W) / Angular Velocity (rad/s)
float absAngVel = Math.Abs( angularVelocity );
float clampedAngularVelocity = (absAngVel > -1f && absAngVel < 1f) ? 1f : angularVelocity;
float torque = powerInWatts / clampedAngularVelocity;
return torque;
}
public float CalculateOutputPowerInKW()
{
return GetPowerInKW( OutputTorque, OutputAngularVelocity );
}
public static float GetPowerInKW( in float torque, in float angularVelocity )
{
// Power (W) = Torque (Nm) * Angular Velocity (rad/s)
float powerInWatts = torque * angularVelocity;
// Convert power from watts to kilowatts
float powerInKW = powerInWatts / 1000f;
return powerInKW;
}
public static float AngularVelocityToRPM( float angularVelocity ) => angularVelocity * 9.5492965855137f;
public static float RPMToAngularVelocity( float RPM ) => RPM * 0.10471975511966f;
} }

View File

@ -0,0 +1,800 @@
using Sandbox;
using System.Collections.Generic;
using System;
namespace VeloX;
public class Transmission : PowertrainComponent
{
protected override void OnAwake()
{
base.OnAwake();
Name ??= "Transmission";
LoadGearsFromGearingProfile();
}
/// <summary>
/// A class representing a single ground surface type.
/// </summary>
public partial class TransmissionGearingProfile
{
/// <summary>
/// List of forward gear ratios starting from 1st forward gear.
/// </summary>
public List<float> ForwardGears { get; set; } = [3.59f, 2.02f, 1.38f, 1f, 0.87f];
/// <summary>
/// List of reverse gear ratios starting from 1st reverse gear.
/// </summary>
public List<float> ReverseGears { get; set; } = [-4f,];
}
public const float INPUT_DEADZONE = 0.05f;
public float ReferenceShiftRPM => _referenceShiftRPM;
private float _referenceShiftRPM;
/// <summary>
/// If true the gear input has to be held for the transmission to stay in gear, otherwise it goes to neutral.
/// Used for hardware H-shifters.
/// </summary>
[Property] public bool HoldToKeepInGear { get; set; }
/// <summary>
/// Final gear multiplier. Each gear gets multiplied by this value.
/// Equivalent to axle/differential ratio in real life.
/// </summary>
[Property] public float FinalGearRatio { get; set; } = 4.3f;
/// <summary>
/// [Obsolete, will be removed]
/// Currently active gearing profile.
/// Final gear ratio will be determined from this and final gear ratio.
/// </summary>
[Property] public TransmissionGearingProfile GearingProfile { get; set; } = new();
/// <summary>
/// A list of gears ratios in order of negative, neutral and then positive.
/// E.g. -4, 0, 6, 4, 3, 2 => one reverse, 4 forward gears.
/// </summary>
[Property, ReadOnly, Group( "Info" )] public List<float> Gears = new();
/// <summary>
/// Number of forward gears.
/// </summary>
public int ForwardGearCount;
/// <summary>
/// Number of reverse gears.
/// </summary>
public int ReverseGearCount;
/// <summary>
/// How much inclines affect shift point position. Higher value will push the shift up and shift down RPM up depending
/// on the current incline to prevent vehicle from upshifting at the wrong time.
/// </summary>
[Property, Range( 0, 4 )] public float InclineEffectCoeff { get; set; }
/// <summary>
/// Function that handles gear shifts.
/// Use External transmission type and assign this delegate manually to use a custom
/// gear shift function.
/// </summary>
public delegate void Shift( VeloXBase vc );
/// <summary>
/// Function that changes the gears as required.
/// Use transmissionType External and assign this delegate to use your own gear shift code.
/// </summary>
public Shift ShiftDelegate;
/// <summary>
/// Event that gets triggered when transmission shifts down.
/// </summary>
public event Action OnGearDownShift;
/// <summary>
/// Event that gets triggered when transmission shifts (up or down).
/// </summary>
public event Action OnGearShift;
/// <summary>
/// Event that gets triggered when transmission shifts up.
/// </summary>
public event Action OnGearUpShift;
/// <summary>
/// Time after shifting in which shifting can not be done again.
/// </summary>
[Property] public float PostShiftBan { get; set; } = 0.5f;
public enum AutomaticTransmissionDNRShiftType
{
Auto,
RequireShiftInput,
RepeatInput,
}
/// <summary>
/// Behavior when switching from neutral to forward or reverse gear.
/// </summary>
[Property] public AutomaticTransmissionDNRShiftType DNRShiftType { get; set; } = AutomaticTransmissionDNRShiftType.Auto;
/// <summary>
/// Speed at which the vehicle can switch between D/N/R gears.
/// </summary>
[Property] public float DnrSpeedThreshold { get; set; } = 0.4f;
/// <summary>
/// If set to >0, the clutch will need to be released to the value below the set number
/// for gear shifts to occur.
/// </summary>
[Property, Range( 0, 1 )] public float ClutchInputShiftThreshold { get; set; } = 1f;
/// <summary>
/// Time it takes transmission to shift between gears.
/// </summary>
[Property] public float ShiftDuration { get; set; } = 0.2f;
/// <summary>
/// Intensity of variable shift point. Higher value will result in shift point moving higher up with higher engine
/// load.
/// </summary>
[Property, Range( 0, 1 )] public float VariableShiftIntensity { get; set; } = 0.3f;
/// <summary>
/// If enabled transmission will adjust both shift up and down points to match current load.
/// </summary>
[Property] public bool VariableShiftPoint { get; set; } = true;
/// <summary>
/// Current gear ratio.
/// </summary>
[Property, ReadOnly] public float CurrentGearRatio { get; set; }
/// <summary>
/// Is the transmission currently in the post-shift phase in which the shifting is disabled/banned to prevent gear hunting?
/// </summary>
[Property, ReadOnly] public bool IsPostShiftBanActive { get; set; }
/// <summary>
/// Is a gear shift currently in progress.
/// </summary>
[Property, ReadOnly] public bool IsShifting { get; set; }
/// <summary>
/// Progress of the current gear shift in range of 0 to 1.
/// </summary>
[Property, ReadOnly] public float ShiftProgress { get; set; }
/// <summary>
/// Current RPM at which transmission will aim to downshift. All the modifiers are taken into account.
/// This value changes with driving conditions.
/// </summary>
[Property]
public float DownshiftRPM
{
get => _downshiftRPM;
set { _downshiftRPM = Math.Clamp( value, 0, float.MaxValue ); }
}
private float _downshiftRPM = 1400;
/// <summary>
/// RPM at which the transmission will try to downshift, but the value might get changed by shift modifier such
/// as incline modifier.
/// To get actual downshift RPM use DownshiftRPM.
/// </summary>
[Property]
public float TargetDownshiftRPM => _targetDownshiftRPM;
private float _targetDownshiftRPM;
/// <summary>
/// RPM at which automatic transmission will shift up. If dynamic shift point is enabled this value will change
/// depending on load.
/// </summary>
[Property]
public float UpshiftRPM
{
get => _upshiftRPM;
set { _upshiftRPM = Math.Clamp( value, 0, float.MaxValue ); }
}
private float _upshiftRPM = 2800;
/// <summary>
/// RPM at which the transmission will try to upshift, but the value might get changed by shift modifier such
/// as incline modifier.
/// To get actual upshift RPM use UpshiftRPM.
/// </summary>
[Property]
public float TargetUpshiftRPM => _targetUpshiftRPM;
private float _targetUpshiftRPM;
public enum TransmissionShiftType
{
Manual,
Automatic,
//CVT,
}
/// <summary>
/// Determines in which way gears can be changed.
/// Manual - gears can only be shifted by manual user input.
/// Automatic - automatic gear changing. Allows for gear skipping (e.g. 3rd->5th) which can be useful in trucks and
/// other high gear count vehicles.
/// AutomaticSequential - automatic gear changing but only one gear at the time can be shifted (e.g. 3rd->4th)
/// </summary>
[Property]
public TransmissionShiftType TransmissionType
{
get => transmissionType; set
{
if ( value == transmissionType )
return;
transmissionType = value;
AssignShiftDelegate();
}
}
/// <summary>
/// Is the automatic gearbox sequential?
/// Has no effect on manual transmission.
/// </summary>
[Property] public bool IsSequential { get; set; } = false;
[Property] public bool AllowUpshiftGearSkipping { get; set; }
[Property] public bool AllowDownshiftGearSkipping { get; set; } = true;
private bool _repeatInputFlag;
private float _smoothedThrottleInput;
/// <summary>
/// Timer needed to prevent manual transmission from slipping out of gear too soon when hold in gear is enabled,
/// which could happen in FixedUpdate() runs twice for one Update() and the shift flag is reset
/// resulting in gearbox thinking it has no shift input.
/// </summary>
private float _slipOutOfGearTimer = -999f;
/// <summary>
/// 0 for neutral, less than 0 for reverse gears and lager than 0 for forward gears.
/// Use 'ShiftInto' to set gear.
/// </summary>
[Property, Sync]
public int Gear
{
get => IndexToGear( gearIndex );
set => gearIndex = GearToIndex( value );
}
/// <summary>
/// Current gear index in the gears list.
/// Different from gear because gear uses -1 = R, 0 = N and D = 1, while this is the apsolute index
/// in the range of 0 to gear list size minus one.
/// Use Gear to get the actual gear.
/// </summary>
public int gearIndex;
private TransmissionShiftType transmissionType = TransmissionShiftType.Automatic;
private int GearToIndex( int g )
{
return g + ReverseGearCount;
}
private int IndexToGear( int g )
{
return g - ReverseGearCount;
}
/// <summary>
/// Returns current gear name as a string, e.g. "R", "R2", "N" or "1"
/// </summary>
public string GearName
{
get
{
int gear = Gear;
if ( _gearNameCache.TryGetValue( gear, out string gearName ) )
return gearName;
if ( gear == 0 )
gearName = "N";
else if ( gear > 0 )
gearName = Gear.ToString();
else
gearName = "R" + (ReverseGearCount > 1 ? -gear : "");
_gearNameCache[gear] = gearName;
return gearName;
}
}
private readonly Dictionary<int, string> _gearNameCache = new();
public void LoadGearsFromGearingProfile()
{
if ( GearingProfile == null )
return;
int totalGears = GearingProfile.ReverseGears.Count + 1 + GearingProfile.ForwardGears.Count;
if ( Gears == null )
Gears = new( totalGears );
else
{
Gears.Clear();
Gears.Capacity = totalGears;
}
Gears.AddRange( GearingProfile.ReverseGears );
Gears.Add( 0 );
Gears.AddRange( GearingProfile.ForwardGears );
}
protected override void OnStart()
{
base.OnStart();
LoadGearsFromGearingProfile();
UpdateGearCounts();
Gear = 0;
AssignShiftDelegate();
}
/// <summary>
/// Total gear ratio of the transmission for current gear.
/// </summary>
private float CalculateTotalGearRatio()
{
//if ( TransmissionType == TransmissionShiftType.CVT )
//{
// float minRatio = Gears[gearIndex];
// float maxRatio = minRatio * 40f;
// float t = Math.Clamp( Controller.Engine.RPMPercent + (1f - Controller.Engine.ThrottlePosition), 0, 1 );
// float ratio = MathX.Lerp( maxRatio, minRatio, t ) * FinalGearRatio;
// return MathX.Lerp( CurrentGearRatio, ratio, Time.Delta * 5f );
//}
//else
return Gears[gearIndex] * FinalGearRatio;
}
public override float QueryAngularVelocity( float angularVelocity, float dt )
{
InputAngularVelocity = angularVelocity;
if ( CurrentGearRatio == 0 || OutputNameHash == 0 )
{
OutputAngularVelocity = 0f;
return angularVelocity;
}
OutputAngularVelocity = InputAngularVelocity / CurrentGearRatio;
return _output.QueryAngularVelocity( OutputAngularVelocity, dt ) * CurrentGearRatio;
}
public override float QueryInertia()
{
if ( OutputNameHash == 0 || CurrentGearRatio == 0 )
return Inertia;
return Inertia + _output.QueryInertia() / (CurrentGearRatio * CurrentGearRatio);
}
/// <summary>
/// Calculates the would-be RPM if none of the wheels was slipping.
/// </summary>
/// <returns>RPM as it would be if the wheels are not slipping or in the air.</returns>
private float CalculateNoSlipRPM()
{
float vehicleLocalVelocity = Controller.LocalVelocity.x.InchToMeter();
// Get the average no-slip wheel RPM
// Use the vehicle velocity as the friction velocities for the wheel are 0 when in air and
// because the shift RPM is not really required to be extremely precise, so slight offset
// between the vehicle position and velocity and the wheel ones is not important.
// Still, calculate for each wheel since radius might be different.
float angVelSum = 0f;
foreach ( VeloXWheel wheelComponent in Controller.MotorWheels )
{
angVelSum += vehicleLocalVelocity / wheelComponent.Radius;
}
// Apply total gear ratio to get the no-slip condition RPM
return AngularVelocityToRPM( angVelSum / Controller.MotorWheels.Count ) * CurrentGearRatio;
}
public override float ForwardStep( float torque, float inertiaSum, float dt )
{
InputTorque = torque;
InputInertia = inertiaSum;
UpdateGearCounts();
if ( _output == null )
return InputTorque;
// Update current gear ratio
CurrentGearRatio = CalculateTotalGearRatio();
// Run the shift function
_referenceShiftRPM = CalculateNoSlipRPM();
ShiftDelegate?.Invoke( Controller );
// Reset any input related to shifting, now that the shifting has been processed
//Controller.Input.ResetShiftFlags();
// Run the physics step
// No output, simply return the torque to the sender
if ( OutputNameHash == 0 )
return torque;
// In neutral, do not send any torque but update components downstram
if ( CurrentGearRatio < 1e-5f && CurrentGearRatio > -1e-5f )
{
OutputTorque = 0;
OutputInertia = InputInertia;
_output.ForwardStep( OutputTorque, OutputInertia, dt );
return torque;
}
// Always send torque to keep wheels updated
OutputTorque = torque * CurrentGearRatio;
OutputInertia = (inertiaSum + Inertia) * (CurrentGearRatio * CurrentGearRatio);
return _output.ForwardStep( torque * CurrentGearRatio, OutputInertia, dt ) / CurrentGearRatio;
}
private void UpdateGearCounts()
{
ForwardGearCount = 0;
ReverseGearCount = 0;
int gearCount = Gears.Count;
for ( int i = 0; i < gearCount; i++ )
{
float gear = Gears[i];
if ( gear > 0 )
ForwardGearCount++;
else if ( gear < 0 )
ReverseGearCount++;
}
}
private void AssignShiftDelegate()
{
if ( TransmissionType == TransmissionShiftType.Manual )
ShiftDelegate = ManualShift;
else if ( TransmissionType == TransmissionShiftType.Automatic )
ShiftDelegate = AutomaticShift;
//else if ( TransmissionType == TransmissionShiftType.CVT )
// ShiftDelegate = CVTShift;
}
private void ManualShift( VeloXBase car )
{
if ( car.IsShiftingUp )
{
ShiftInto( Gear + 1 );
return;
}
if ( car.IsShiftingDown )
{
ShiftInto( Gear - 1 );
return;
}
if ( HoldToKeepInGear )
{
_slipOutOfGearTimer += Time.Delta;
if ( Gear != 0 && _slipOutOfGearTimer > 0.1f )
ShiftInto( 0 );
}
}
/// <summary>
/// Shifts into given gear. 0 for neutral, less than 0 for reverse and above 0 for forward gears.
/// Does nothing if the target gear is equal to current gear.
/// </summary>
public void ShiftInto( int targetGear, bool instant = false )
{
// Clutch is not pressed above the set threshold, exit and do not shift.
if ( Controller.IsClutching > ClutchInputShiftThreshold )
return;
int currentGear = Gear;
bool isShiftFromOrToNeutral = targetGear == 0 || currentGear == 0;
//Debug.Log($"Shift from {currentGear} into {targetGear}");
// Check if shift can happen at all
if ( targetGear == currentGear || targetGear < -100 )
return;
// Convert gear to gear list index
int targetIndex = GearToIndex( targetGear );
// Check for gear list bounds
if ( targetIndex < 0 || targetIndex >= Gears.Count )
return;
if ( !IsShifting && (isShiftFromOrToNeutral || !IsPostShiftBanActive) )
{
ShiftCoroutine( currentGear, targetGear, isShiftFromOrToNeutral || instant );
// If in neutral reset the repeated input flat required for repeat input reverse
if ( targetGear == 0 )
_repeatInputFlag = false;
}
}
private async void ShiftCoroutine( int currentGear, int targetGear, bool instant )
{
if ( IsShifting )
return;
float dt = Time.Delta;
bool isManual = TransmissionType == TransmissionShiftType.Manual;
//Debug.Log($"Shift from {currentGear} to {targetGear}, instant: {instant}");
// Immediately start shift ban to prevent repeated shifts while this one has not finished
if ( !isManual )
IsPostShiftBanActive = true;
IsShifting = true;
ShiftProgress = 0f;
// Run the first half of shift timer
float shiftTimer = 0;
float halfDuration = ShiftDuration * 0.5f;
if ( !instant )
while ( shiftTimer < halfDuration )
{
ShiftProgress = shiftTimer / ShiftDuration;
shiftTimer += dt;
await Task.DelayRealtimeSeconds( dt );
}
// Do the shift at the half point of shift duration
Gear = targetGear;
if ( currentGear < targetGear )
OnGearUpShift?.Invoke();
else
OnGearDownShift?.Invoke();
OnGearShift?.Invoke();
// Run the second half of the shift timer
if ( !instant )
while ( shiftTimer < ShiftDuration )
{
ShiftProgress = shiftTimer / ShiftDuration;
shiftTimer += dt;
await Task.DelayRealtimeSeconds( dt );
}
// Shift has finished
ShiftProgress = 1f;
IsShifting = false;
// Run post shift ban only if not manual as blocking user input feels unresponsive and post shift ban
// exists to prevent auto transmission from hunting.
if ( !isManual )
{
// Post shift ban timer
float postShiftBanTimer = 0;
while ( postShiftBanTimer < PostShiftBan )
{
postShiftBanTimer += dt;
await Task.DelayRealtimeSeconds( dt );
}
// Post shift ban has finished
IsPostShiftBanActive = false;
}
}
private void CVTShift( VeloXBase car ) => AutomaticShift( car );
/// <summary>
/// Handles automatic and automatic sequential shifting.
/// </summary>
private void AutomaticShift( VeloXBase car )
{
float vehicleSpeed = car.ForwardSpeed;
float throttleInput = car.SwappedThrottle;
float brakeInput = car.SwappedBrakes;
int currentGear = Gear;
// Assign base shift points
_targetDownshiftRPM = _downshiftRPM;
_targetUpshiftRPM = _upshiftRPM;
// Calculate shift points for variable shift RPM
if ( VariableShiftPoint )
{
// Smooth throttle input so that the variable shift point does not shift suddenly and cause gear hunting
_smoothedThrottleInput = MathX.Lerp( _smoothedThrottleInput, throttleInput, Time.Delta * 2f );
float revLimiterRPM = car.Engine.RevLimiterRPM;
_targetUpshiftRPM = _upshiftRPM + Math.Clamp( _smoothedThrottleInput * VariableShiftIntensity, 0f, 1f ) * _upshiftRPM;
_targetUpshiftRPM = Math.Clamp( _targetUpshiftRPM, _upshiftRPM, revLimiterRPM * 0.97f );
_targetDownshiftRPM = _downshiftRPM + Math.Clamp( _smoothedThrottleInput * VariableShiftIntensity, 0f, 1f ) * _downshiftRPM;
_targetDownshiftRPM = Math.Clamp( _targetDownshiftRPM, car.Engine.IdleRPM * 1.1f, _targetUpshiftRPM * 0.7f );
// Add incline modifier
float inclineModifier = Math.Clamp( car.WorldRotation.Forward.Dot( Vector3.Up ) * InclineEffectCoeff, 0f, 1f );
_targetUpshiftRPM += revLimiterRPM * inclineModifier;
_targetDownshiftRPM += revLimiterRPM * inclineModifier;
}
// In neutral
if ( currentGear == 0 )
{
if ( DNRShiftType == AutomaticTransmissionDNRShiftType.Auto )
{
if ( throttleInput > INPUT_DEADZONE )
ShiftInto( 1 );
else if ( brakeInput > INPUT_DEADZONE )
ShiftInto( -1 );
}
else if ( DNRShiftType == AutomaticTransmissionDNRShiftType.RequireShiftInput )
{
if ( car.IsShiftingUp )
ShiftInto( 1 );
else if ( car.IsShiftingDown )
ShiftInto( -1 );
}
else if ( DNRShiftType == AutomaticTransmissionDNRShiftType.RepeatInput )
{
if ( _repeatInputFlag == false && throttleInput < INPUT_DEADZONE && brakeInput < INPUT_DEADZONE )
_repeatInputFlag = true;
if ( _repeatInputFlag )
{
if ( throttleInput > INPUT_DEADZONE )
ShiftInto( 1 );
else if ( brakeInput > INPUT_DEADZONE )
ShiftInto( -1 );
}
}
}
// In reverse
else if ( currentGear < 0 )
{
// Shift into neutral
if ( DNRShiftType == AutomaticTransmissionDNRShiftType.RequireShiftInput )
{
if ( car.IsShiftingUp )
ShiftInto( 0 );
}
else
{
if ( vehicleSpeed < DnrSpeedThreshold && (brakeInput > INPUT_DEADZONE || throttleInput < INPUT_DEADZONE) )
ShiftInto( 0 );
}
// Reverse upshift
float absGearMinusOne = currentGear - 1;
absGearMinusOne = absGearMinusOne < 0 ? -absGearMinusOne : absGearMinusOne;
if ( _referenceShiftRPM > TargetUpshiftRPM && absGearMinusOne < ReverseGearCount )
ShiftInto( currentGear - 1 );
// Reverse downshift
else if ( _referenceShiftRPM < TargetDownshiftRPM && currentGear < -1 )
ShiftInto( currentGear + 1 );
}
// In forward
else
{
if ( vehicleSpeed > 0.4f )
{
// Upshift
if ( currentGear < ForwardGearCount && _referenceShiftRPM > TargetUpshiftRPM )
{
if ( !IsSequential && AllowUpshiftGearSkipping )
{
int g = currentGear;
while ( g < ForwardGearCount )
{
g++;
float wouldBeEngineRPM = ReverseTransmitRPM( _referenceShiftRPM / CurrentGearRatio, g );
float shiftDurationPadding = Math.Clamp( ShiftDuration, 0, 1 ) * (_targetUpshiftRPM - _targetDownshiftRPM) * 0.25f;
if ( wouldBeEngineRPM < _targetDownshiftRPM + shiftDurationPadding )
{
g--;
break;
}
}
if ( g != currentGear )
{
ShiftInto( g );
}
}
else
{
ShiftInto( currentGear + 1 );
}
}
// Downshift
else if ( _referenceShiftRPM < TargetDownshiftRPM )
{
// Non-sequential
if ( !IsSequential && AllowDownshiftGearSkipping )
{
if ( currentGear != 1 )
{
int g = currentGear;
while ( g > 1 )
{
g--;
float wouldBeEngineRPM = ReverseTransmitRPM( _referenceShiftRPM / CurrentGearRatio, g );
if ( wouldBeEngineRPM > _targetUpshiftRPM )
{
g++;
break;
}
}
if ( g != currentGear )
{
ShiftInto( g );
}
}
else if ( vehicleSpeed < DnrSpeedThreshold && throttleInput < INPUT_DEADZONE
&& DNRShiftType !=
AutomaticTransmissionDNRShiftType
.RequireShiftInput )
{
ShiftInto( 0 );
}
}
// Sequential
else
{
if ( currentGear != 1 )
{
ShiftInto( currentGear - 1 );
}
else if ( vehicleSpeed < DnrSpeedThreshold && throttleInput < INPUT_DEADZONE &&
brakeInput < INPUT_DEADZONE
&& DNRShiftType !=
AutomaticTransmissionDNRShiftType.RequireShiftInput )
{
ShiftInto( 0 );
}
}
}
}
// Shift into neutral
else
{
if ( DNRShiftType != AutomaticTransmissionDNRShiftType.RequireShiftInput )
{
if ( throttleInput < INPUT_DEADZONE )
{
ShiftInto( 0 );
}
}
else
{
if ( car.IsShiftingDown )
{
ShiftInto( 0 );
}
}
}
}
}
/// <summary>
/// Converts axle RPM to engine RPM for given gear in Gears list.
/// </summary>
public float ReverseTransmitRPM( float inputRPM, int g )
{
float outRpm = inputRPM * Gears[GearToIndex( g )] * FinalGearRatio;
return Math.Abs( outRpm );
}
}

View File

@ -0,0 +1,67 @@
using Sandbox;
using System;
namespace VeloX;
public partial class WheelPowertrain : PowertrainComponent
{
protected override void OnAwake()
{
base.OnAwake();
Name ??= Wheel.ToString();
}
[Property] public VeloXWheel Wheel { get; set; }
protected override void OnStart()
{
_initialRollingResistance = Wheel.RollingResistanceTorque;
}
private float _initialRollingResistance;
public override float QueryAngularVelocity( float angularVelocity, float dt )
{
InputAngularVelocity = OutputAngularVelocity = Wheel.AngularVelocity;
return OutputAngularVelocity;
}
public override float QueryInertia()
{
// Calculate the base inertia of the wheel and scale it by the inverse of the dt.
float dtScale = Math.Clamp( Time.Delta, 0.01f, 0.05f ) / 0.005f;
float radius = Wheel.Radius;
return 0.5f * Wheel.Mass * radius * radius * dtScale;
}
public void ApplyRollingResistanceMultiplier( float multiplier )
{
Wheel.RollingResistanceTorque = _initialRollingResistance * multiplier;
}
public override float ForwardStep( float torque, float inertiaSum, float dt )
{
InputTorque = torque;
InputInertia = inertiaSum;
OutputTorque = InputTorque;
OutputInertia = Wheel.BaseInertia + inertiaSum;
Wheel.DriveTorque = OutputTorque;
Wheel.Inertia = OutputInertia;
Wheel.AutoSimulate = false;
Wheel.StepPhys( Controller, dt );
return Math.Abs( Wheel.CounterTorque );
}
protected override void DrawGizmos()
{
if ( !Gizmo.IsSelected )
return;
Gizmo.Transform = Wheel.WorldTransform;
Wheel?.GizmoDraw();
}
}

View File

@ -0,0 +1,128 @@
using Sandbox;
using System;
using System.Collections.Generic;
namespace VeloX;
public abstract partial class VeloXBase
{
[Property, Feature( "Powertrain" )] public Engine Engine { get; set; }
[Property, Feature( "Powertrain" )] public Clutch Clutch { get; set; }
[Property, Feature( "Powertrain" )] public Transmission Transmission { get; set; }
[Property, Feature( "Powertrain" )] public Differential Differential { get; set; }
[Property, Feature( "Powertrain" )] public List<VeloXWheel> MotorWheels { get; set; }
private GameObject powertrainGameObject;
[Button, Feature( "Powertrain" )]
internal void CreatePowertrain()
{
using var undoScope = Scene.Editor?.UndoScope( "Create Powertrain" ).WithComponentCreations().WithGameObjectCreations().Push();
if ( !powertrainGameObject.IsValid() )
{
powertrainGameObject = null;
}
powertrainGameObject ??= new GameObject( true, "Powertrain" );
if ( !Engine.IsValid() )
Engine = new GameObject( powertrainGameObject, true, "Engine" ).GetOrAddComponent<Engine>();
Engine.Controller = this;
Engine.Inertia = 0.25f;
Engine.Ignition = false;
if ( !Clutch.IsValid() )
Clutch = new GameObject( Engine.GameObject, true, "Clutch" ).GetOrAddComponent<Clutch>();
Clutch.Controller = this;
Clutch.Inertia = 0.02f;
Engine.Output = Clutch;
if ( !Transmission.IsValid() )
Transmission = new GameObject( Clutch.GameObject, true, "Transmission" ).GetOrAddComponent<Transmission>();
Transmission.Controller = this;
Transmission.Inertia = 0.01f;
Clutch.Output = Transmission;
Differential = new TreeBuilder( Transmission, MotorWheels ).Root.Diff;
Transmission.Output = Differential;
//PowertrainWheels = Differential.Components.GetAll<WheelPowertrain>( FindMode.InDescendants ).ToList();
}
}
internal class TreeNode
{
internal TreeNode Left { get; set; }
internal TreeNode Right { get; set; }
public WheelPowertrain Item { get; set; }
public Differential Diff { get; set; }
public bool IsLeaf => Left == null && Right == null;
}
internal class TreeBuilder
{
internal TreeNode Root { get; private set; }
internal TreeBuilder( PowertrainComponent parent, List<VeloXWheel> items )
{
if ( items == null || items.Count == 0 )
throw new ArgumentException( "Items list cannot be null or empty." );
Root = BuildTree( parent, items, 0, items.Count - 1 );
}
private static TreeNode BuildTree( PowertrainComponent parent, List<VeloXWheel> items, int start, int end )
{
if ( start > end )
return null;
if ( start == end )
{
var leaf = new TreeNode() { Item = new GameObject( parent.GameObject, true, $"Wheel {items[start].GameObject.Name}" ).GetOrAddComponent<WheelPowertrain>() };
leaf.Item.Controller = parent.Controller;
leaf.Item.Wheel = items[start];
leaf.Item.Inertia = 0.01f;
var parentd = parent as Differential;
if ( (start + 1) % 2 == 0 )
{
GameTask.RunInThreadAsync( () =>
{
parentd.OutputB = leaf.Item;
} );
}
else
{
GameTask.RunInThreadAsync( () =>
{
parent.Output = leaf.Item;
} );
}
return leaf;
}
int mid = (start + end) / 2;
var diff = new GameObject( parent.GameObject, true, "Differential" ).GetOrAddComponent<Differential>();
diff.Controller = parent.Controller;
diff.Inertia = 0.1f;
var node = new TreeNode
{
Left = BuildTree( diff, items, start, mid ),
Right = BuildTree( diff, items, mid + 1, end ),
Diff = diff
};
diff.Output = node.Left.Diff;
diff.OutputB = node.Right.Diff;
return node;
}
}

View File

@ -4,20 +4,150 @@ namespace VeloX;
public abstract partial class VeloXBase public abstract partial class VeloXBase
{ {
[Property, Feature( "Input" )] internal InputResolver Input { get; set; } = new(); [Feature( "Input" )] internal InputResolver Input { get; set; } = new();
[Feature( "Input" )] public Connection Driver { get => Input.Driver; set => Input.Driver = value; }
private Guid _guid;
[Sync( SyncFlags.FromHost )] private bool IsDriverActive => Driver is not null;
public Guid ConnectionID
public Vector2 MouseDelta => IsDriverActive ? Input.MouseDelta : default;
public Vector2 MouseWheel => IsDriverActive ? Input.MouseWheel : default;
public Angles AnalogLook => IsDriverActive ? Input.AnalogLook : default;
public Vector3 AnalogMove => IsDriverActive ? Input.AnalogMove : default;
private float throttle;
private float brakes;
private float steerAngle;
private float handbrake;
public bool CanInputSwapping { get; set; } = true;
public bool IsInputSwapped => CanInputSwapping && Transmission.Gear < 0;
public bool IsShiftingDown { get; private set; }
public bool IsShiftingUp { get; private set; }
public float IsClutching { get; private set; }
public float SwappedThrottle => IsInputSwapped ? Brakes : Throttle;
public float SwappedBrakes => IsInputSwapped ? Throttle : Brakes;
[Sync]
public float VerticalInput
{ {
get => _guid; get => throttle - brakes;
set set
{ {
_guid = value; float clampedValue = Math.Clamp( value, -1, 1 );
Input.Driver = Connection.Find( _guid );
if ( value > 0 )
{
throttle = clampedValue;
brakes = 0;
}
else
{
throttle = 0;
brakes = -clampedValue;
} }
} }
[Property, Feature( "Input" )] public bool IsDriver => ConnectionID == Connection.Local.Id; }
/// <summary>
/// Throttle axis.
/// For combined throttle/brake input use 'VerticalInput' instead.
/// </summary>
[Sync( SyncFlags.Interpolate ), Range( 0, 1 ), Property]
public float Throttle
{
get => throttle;
set => throttle = Math.Clamp( value, 0, 1 );
}
/// <summary>
/// Brake axis.
/// For combined throttle/brake input use 'VerticalInput' instead.
/// </summary>
[Sync]
public float Brakes
{
get => brakes;
set => brakes = Math.Clamp( value, 0, 1 );
}
[Sync]
public float SteeringAngle
{
get => steerAngle;
set => steerAngle = Math.Clamp( value, -1, 1 );
}
[Sync]
public float Handbrake
{
get => handbrake;
set => handbrake = Math.Clamp( value, 0, 1 );
}
public void ResetInput()
{
VerticalInput = 0;
Handbrake = 0;
SteeringAngle = 0;
IsClutching = 0;
IsShiftingUp = false;
IsShiftingDown = false;
}
private void UpdateInput()
{
VerticalInput = TotalSpeed < 10 ? Input.AnalogMove.x * 0.5f : Input.AnalogMove.x;
Handbrake = Input.Down( "Jump" ) ? 1 : 0;
SteeringAngle = Input.AnalogMove.y;
IsClutching = (Input.Down( "Run" ) || Input.Down( "Jump" )) ? 1 : 0;
IsShiftingUp = Input.Pressed( "Attack1" );
IsShiftingDown = Input.Pressed( "Attack2" );
if ( TotalSpeed < 150 && Driver is null )
Handbrake = 1;
}
public bool Down( string action )
{
return IsDriverActive && Input.Down( action );
}
public bool Pressed( string action )
{
return IsDriverActive && Input.Pressed( action );
}
public bool Released( string action )
{
return IsDriverActive && Input.Released( action );
}
public void TriggerHaptics( float leftMotor, float rightMotor, float leftTrigger = 0f, float rightTrigger = 0f, int duration = 500 )
{
if ( IsDriverActive )
{
Input.TriggerHaptics( leftMotor, rightMotor, leftTrigger, rightTrigger, duration );
}
}
public void StopAllHaptics()
{
if ( IsDriverActive )
Input.StopAllHaptics();
}
} }

View File

@ -1,41 +1,58 @@
using Sandbox; using Sandbox;
using System;
namespace VeloX; namespace VeloX;
public abstract partial class VeloXBase public abstract partial class VeloXBase
{ {
private Vector3 linForce;
private Vector3 angForce; private Vector3 angForce;
[Property] float BrakeForce { get; set; } = 1500f;
[Property] float HandbrakeForce { get; set; } = 3500f;
private void PhysicsSimulate() private void PhysicsSimulate()
{ {
if ( Body.Sleeping && Input.AnalogMove.x == 0 )
return;
var drag = AngularDrag; var drag = AngularDrag;
var mass = Body.Mass; var mass = Body.Mass;
var angVel = Body.AngularVelocity; var angVel = Body.AngularVelocity;
angForce.x = angVel.x * drag.x * mass; linForce.x = 0;
angForce.y = angVel.y * drag.y * mass; linForce.y = 0;
angForce.z = angVel.z * drag.z * mass; linForce.z = 0;
angForce = angForce.WithX( angVel.x * drag.x * mass * 1000 );
angForce = angForce.WithY( angVel.y * drag.y * mass * 1000 );
angForce = angForce.WithZ( angVel.z * drag.z * mass * 1000 );
if ( Wheels.Count > 0 ) if ( Wheels.Count > 0 )
foreach ( var v in Wheels )
if ( v.AutoPhysics ) v.DoPhysics( this );
var totalSpeed = TotalSpeed + Math.Abs( Body.AngularVelocity.z );
var factor = 1 - Math.Clamp( totalSpeed / 30, 0, 1 );
if ( factor > 0.1f )
{ {
var vel = Body.Velocity; Vector3 vehVel = Body.Velocity;
Vector3 vehAngVel = Body.AngularVelocity;
var rt = WorldRotation.Right; var dt = Time.Delta;
CombinedLoad = 0;
foreach ( var v in Wheels )
CombinedLoad += v.Fz;
foreach ( var v in Wheels )
{
v.BrakeTorque = SwappedBrakes * BrakeForce;
if ( !v.IsFront )
v.BrakeTorque += Handbrake * HandbrakeForce;
var force = rt.Dot( vel ) / Time.Delta * mass * factor * rt; v.Update( this, in dt );
Body.ApplyForce( -force ); v.DoPhysics( in dt );
} }
Body.Velocity = vehVel;
Body.AngularVelocity = vehAngVel;
}
Body.ApplyForce( linForce );
Body.ApplyTorque( angForce ); Body.ApplyTorque( angForce );
} }
} }

View File

@ -1,13 +1,16 @@
using Sandbox; using Sandbox;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace VeloX; namespace VeloX;
public abstract partial class VeloXBase public abstract partial class VeloXBase
{ {
public float CombinedLoad { get; protected set; }
[Property] public List<VeloXWheel> Wheels { get; set; } [Property] public List<VeloXWheel> Wheels { get; set; }
[Property] public TagSet WheelIgnoredTags { get; set; } [Property] public TagSet WheelIgnoredTags { get; set; }
public bool IsOnGround => Wheels.Any( x => x.IsOnGround );
public List<VeloXWheel> FindWheels() => [.. Components.GetAll<VeloXWheel>()]; public List<VeloXWheel> FindWheels() => [.. Components.GetAll<VeloXWheel>()];
@ -16,5 +19,4 @@ public abstract partial class VeloXBase
{ {
Wheels = FindWheels(); Wheels = FindWheels();
} }
} }

View File

@ -7,8 +7,7 @@ public abstract partial class VeloXBase : Component
{ {
[Sync] public WaterState WaterState { get; set; } [Sync] public WaterState WaterState { get; set; }
[Sync] public bool IsEngineOnFire { get; set; } [Sync] public bool IsEngineOnFire { get; set; }
[Sync, Range( 0, 1 ), Property] public float Brake { get; set; } [Property, Sync] public EngineState EngineState { get; set; }
[Sync( SyncFlags.Interpolate ), Range( 0, 1 ), Property] public float Throttle { get; set; }
[Property] public Vector3 AngularDrag { get; set; } = new( -0.1f, -0.1f, -3 ); [Property] public Vector3 AngularDrag { get; set; } = new( -0.1f, -0.1f, -3 );
[Property] public float Mass { get; set; } = 900; [Property] public float Mass { get; set; } = 900;
@ -23,7 +22,7 @@ public abstract partial class VeloXBase : Component
protected override void OnFixedUpdate() protected override void OnFixedUpdate()
{ {
if ( !IsDriver ) if ( IsProxy )
return; return;
LocalVelocity = WorldTransform.PointToLocal( WorldPosition + Body.Velocity ); LocalVelocity = WorldTransform.PointToLocal( WorldPosition + Body.Velocity );
@ -31,7 +30,9 @@ public abstract partial class VeloXBase : Component
TotalSpeed = LocalVelocity.Length; TotalSpeed = LocalVelocity.Length;
Body.PhysicsBody.Mass = Mass; Body.PhysicsBody.Mass = Mass;
UpdateInput();
PhysicsSimulate(); PhysicsSimulate();
} }
} }

View File

@ -1,43 +0,0 @@
using Sandbox;
using Sandbox.Services;
using System;
using System.Threading;
namespace VeloX;
public class Pacejka
{
public class PacejkaPreset
{
[KeyProperty] public float B { get; set; } = 10.86f;
[KeyProperty] public float C { get; set; } = 2.15f;
[KeyProperty] public float D { get; set; } = 0.933f;
[KeyProperty] public float E { get; set; } = 0.992f;
public float Evaluate( float slip ) => D * MathF.Sin( C * MathF.Atan( B * slip - E * (B * slip - MathF.Atan( B * slip )) ) );
public float GetPeakSlip()
{
float peakSlip = -1;
float yMax = 0;
for ( float i = 0; i < 1f; i += 0.01f )
{
float y = Evaluate( i );
if ( y > yMax )
{
yMax = y;
peakSlip = i;
}
}
return peakSlip;
}
}
public PacejkaPreset Lateral { get; set; } = new();
public PacejkaPreset Longitudinal { get; set; } = new();
public float PacejkaFx( float slip ) => Longitudinal.Evaluate( slip );
public float PacejkaFy( float slip ) => Lateral.Evaluate( slip );
}

View File

@ -1,18 +0,0 @@
namespace VeloX;
public class BasicSuspension
{
private readonly Hinge Hinge;
public BasicSuspension( Vector3 wheel, Vector3 hingeBody, Vector3 hingeWheel )
{
Vector3 hingePoint = wheel - (hingeWheel - hingeBody);
Hinge = new Hinge( hingePoint, hingeWheel - hingeBody );
}
public virtual void GetWheelTransform( float travel, out Rotation rotation, out Vector3 position )
{
rotation = Rotation.Identity;
position = Hinge.Rotate( travel );
}
}

View File

@ -1,26 +0,0 @@
using System;
namespace VeloX;
internal readonly struct Hinge( Vector3 hinge_anchor, Vector3 hinge_arm )
{
[Description( "the point that the wheels are rotated around as the suspension compresses" )]
public readonly Vector3 Anchor = hinge_anchor;
[Description( "anchor to wheel vector" )]
public readonly Vector3 Arm = hinge_arm;
[Description( "arm length squared" )]
public readonly float LengthSquared = hinge_arm.Dot( hinge_arm );
[Description( "1 / arm length in hinge axis normal plane" )]
public readonly float NormXY = 1 / MathF.Sqrt( hinge_arm.x * hinge_arm.x + hinge_arm.y * hinge_arm.y );
public readonly Vector3 Rotate( float travel )
{
float z = Arm.z + travel;
float lengthSq = MathF.Max( LengthSquared - z * z, 0.0f );
float nxy = NormXY * MathF.Sqrt( lengthSq );
return Anchor + new Vector3( Arm.x * nxy, Arm.y * nxy, z );
}
}

View File

@ -1,32 +0,0 @@
using Sandbox;
using System;
namespace VeloX;
public class MacPhersonSuspension
{
public readonly Vector3 WheelOffset;
public readonly Vector3 UprightTop;
public readonly Vector3 UprightAxis;
private readonly Hinge Hinge;
public MacPhersonSuspension( Vector3 wheel, Vector3 strutBody, Vector3 strutWheel, Vector3 hingeBody )
{
WheelOffset = wheel - strutWheel;
UprightTop = strutBody;
UprightAxis = (strutBody - strutWheel).Normal;
Hinge = new( hingeBody, strutWheel - hingeBody );
}
public void GetWheelTransform( float travel, out Rotation rotation, out Vector3 position )
{
Vector3 hingeEnd = Hinge.Rotate( travel );
Vector3 uprightAxisNew = (UprightTop - hingeEnd).Normal;
rotation = Rotation.FromAxis(
Vector3.Cross( UprightAxis, uprightAxisNew ),
MathF.Acos( Vector3.Dot( UprightAxis, uprightAxisNew ) ).RadianToDegree()
);
position = hingeEnd + WheelOffset.Transform( rotation );
}
}

View File

@ -4,57 +4,45 @@ using System;
namespace VeloX; namespace VeloX;
[GameResource( "Wheel Friction", "tire", "Wheel Friction", Category = "VeloX", Icon = "radio_button_checked" )] [AssetType( Name = "Wheel Friction", Extension = "tire", Category = "VeloX" )]
public class TirePreset : GameResource public class TirePreset : GameResource
{ {
public static TirePreset Default { get; } = ResourceLibrary.Get<TirePreset>( "frictions/default.tire" );
[Property] public Pacejka Pacejka { get; set; } private float peakSlip = -1;
public float RollResistanceLin { get; set; } = 1E-3f; [Property] public float B { get; set; } = 10.86f;
public float RollResistanceQuad { get; set; } = 1E-6f; [Property] public float C { get; set; } = 2.15f;
[Property] public float D { get; set; } = 0.933f;
[Property] public float E { get; set; } = 0.992f;
public float GetRollingResistance( float velocity, float resistance_factor ) public float GetPeakSlip()
{ // surface influence on rolling resistance
float resistance = resistance_factor * RollResistanceLin;
// heat due to tire deformation increases rolling resistance
// approximate by quadratic function
resistance += velocity * velocity * RollResistanceQuad;
return resistance;
}
public static void ComputeSlip( float lon_velocity, float lat_velocity, float rot_velocity, float wheel_radius, out float slip_ratio, out float slip_angle )
{ {
var abs_lon = Math.Max( MathF.Abs( lon_velocity ), 1e-3f ); if ( peakSlip == -1 )
peakSlip = CalcPeakSlip();
slip_ratio = lon_velocity - rot_velocity * wheel_radius;
if ( abs_lon >= 0.005f )
slip_ratio /= abs_lon;
else
slip_ratio *= abs_lon;
if ( abs_lon >= 0.5f )
slip_angle = MathF.Atan2( -lat_velocity, abs_lon ).RadianToDegree() / 50f;
else
slip_angle = -lat_velocity * (0.01f / Time.Delta);
slip_ratio = Math.Clamp( slip_ratio, -1, 1 );
slip_angle = Math.Clamp( slip_angle, -1, 1 );
return peakSlip;
} }
/// approximate asin(x) = x + x^3/6 for +-18 deg range
public static float ComputeCamberAngle( float sin_camber ) public float Evaluate( float t ) => D * MathF.Sin( C * MathF.Atan( B * t - E * (B * t - MathF.Atan( B * t )) ) );
private float CalcPeakSlip()
{ {
float sc = Math.Clamp( sin_camber, -0.3f, 0.3f ); float peakSlip = -1;
return ((1 / 6.0f) * (sc * sc) + 1) * sc; float yMax = 0;
for ( float i = 0; i < 1f; i += 0.01f )
{
float y = Evaluate( i );
if ( y > yMax )
{
yMax = y;
peakSlip = i;
}
} }
public static float ComputeCamberVelocity( float sa, float vx ) return peakSlip;
{
float tansa = (1 / 3.0f * (sa * sa) + 1) * sa;
return tansa * vx;
} }
} }

View File

@ -0,0 +1,295 @@
using Sandbox;
using System;
namespace VeloX;
public partial class VeloXWheel
{
/// <summary>
/// Constant torque acting similar to brake torque.
/// Imitates rolling resistance.
/// </summary>
[Property, Range( 0, 500 ), Sync] public float RollingResistanceTorque { get; set; } = 30f;
/// <summary>
/// The percentage this wheel is contributing to the total vehicle load bearing.
/// </summary>
public float LoadContribution { get; set; } = 0.25f;
/// <summary>
/// Maximum load the tire is rated for in [N].
/// Used to calculate friction.Default value is adequate for most cars but
/// larger and heavier vehicles such as semi trucks will use higher values.
/// A good rule of the thumb is that this value should be 2x the Load
/// while vehicle is stationary.
/// </summary>
[Property, Sync] public float LoadRating { get; set; } = 5400;
/// <summary>
/// The amount of torque returned by the wheel.
/// Under no-slip conditions this will be equal to the torque that was input.
/// When there is wheel spin, the value will be less than the input torque.
/// </summary>
public float CounterTorque { get; private set; }
[Property, Range( 0, 2 )] public float BrakeMult { get; set; } = 1f;
public Friction ForwardFriction = new();
public Friction SidewayFriction = new();
public Vector3 FrictionForce;
private Vector3 hitContactVelocity;
private Vector3 hitForwardDirection;
private Vector3 hitSidewaysDirection;
public Vector3 ContactRight => hitSidewaysDirection;
public Vector3 ContactForward => hitForwardDirection;
[Sync]
public float LongitudinalSlip
{
get => ForwardFriction.Slip;
private set => ForwardFriction.Slip = value;
}
public float LongitudinalSpeed => ForwardFriction.Speed;
public bool IsSkiddingLongitudinally => NormalizedLongitudinalSlip > 0.35f;
public float NormalizedLongitudinalSlip => Math.Clamp( Math.Abs( LongitudinalSlip ), 0, 1 );
[Sync]
public float LateralSlip
{
get => SidewayFriction.Slip;
private set => SidewayFriction.Slip = value;
}
public float LateralSpeed => SidewayFriction.Speed;
public bool IsSkiddingLaterally => NormalizedLateralSlip > 0.35f;
public float NormalizedLateralSlip => Math.Clamp( Math.Abs( LateralSlip ), 0, 1 );
public bool IsSkidding => IsSkiddingLaterally || IsSkiddingLongitudinally;
public float NormalizedSlip => (NormalizedLateralSlip + NormalizedLongitudinalSlip) / 2f;
private void UpdateHitVariables()
{
if ( IsOnGround )
{
hitContactVelocity = Vehicle.Body.GetVelocityAtPoint( ContactPosition + Vehicle.Body.MassCenter );
hitForwardDirection = ContactNormal.Cross( TransformRotationSteer.Right ).Normal;
hitSidewaysDirection = Rotation.FromAxis( ContactNormal, 90f ) * hitForwardDirection;
ForwardFriction.Speed = hitContactVelocity.Dot( hitForwardDirection ).InchToMeter();
SidewayFriction.Speed = hitContactVelocity.Dot( hitSidewaysDirection ).InchToMeter();
}
else
{
ForwardFriction.Speed = 0f;
SidewayFriction.Speed = 0f;
}
}
private Vector3 lowSpeedReferencePosition;
private bool lowSpeedReferenceIsSet;
private Vector3 currentPosition;
private Vector3 referenceError;
private Vector3 correctiveForce;
private void UpdateFriction( float dt )
{
var motorTorque = DriveTorque;
var brakeTorque = BrakeTorque * BrakeMult;
float allWheelLoadSum = Vehicle.CombinedLoad;
LoadContribution = allWheelLoadSum == 0 ? 1f : Fz / allWheelLoadSum;
float mRadius = Radius;
float invDt = 1f / dt;
float invRadius = 1f / mRadius;
float inertia = Inertia;
float invInertia = 1f / Inertia;
float loadClamped = Math.Clamp( Fz, 0, LoadRating );
float forwardLoadFactor = loadClamped * 1.35f;
float sideLoadFactor = loadClamped * 1.9f;
float loadPercent = Math.Clamp( Fz / LoadRating, 0f, 1f );
float slipLoadModifier = 1f - loadPercent * 0.4f;
//DebugOverlay.Text( WorldPosition, SidewayFriction.Speed.ToString(), overlay: true );
float mass = Vehicle.Body.Mass;
float absForwardSpeed = Math.Abs( ForwardFriction.Speed );
float forwardForceClamp = mass * LoadContribution * absForwardSpeed * invDt;
float absSideSpeed = Math.Abs( SidewayFriction.Speed );
float sideForceClamp = mass * LoadContribution * absSideSpeed * invDt;
float forwardSpeedClamp = 1.5f * (dt / 0.005f);
forwardSpeedClamp = Math.Clamp( forwardSpeedClamp, 1.5f, 10f );
float clampedAbsForwardSpeed = Math.Max( absForwardSpeed, forwardSpeedClamp );
// Calculate effect of camber on friction
float camberFrictionCoeff = Math.Max( 0, Vehicle.WorldRotation.Up.Dot( ContactNormal ) );
float peakForwardFrictionForce = forwardLoadFactor;
float absCombinedBrakeTorque = Math.Max( 0, brakeTorque + RollingResistanceTorque );
float signedCombinedBrakeTorque = absCombinedBrakeTorque * -Math.Sign( ForwardFriction.Speed );
float signedCombinedBrakeForce = signedCombinedBrakeTorque * invRadius;
float motorForce = motorTorque * invRadius;
float forwardInputForce = motorForce + signedCombinedBrakeForce;
float absMotorTorque = Math.Abs( motorTorque );
float absBrakeTorque = Math.Abs( brakeTorque );
float maxForwardForce = Math.Min( peakForwardFrictionForce, forwardForceClamp );
maxForwardForce = absMotorTorque < absBrakeTorque ? maxForwardForce : peakForwardFrictionForce;
ForwardFriction.Force = forwardInputForce > maxForwardForce ? maxForwardForce
: forwardInputForce < -maxForwardForce ? -maxForwardForce : forwardInputForce;
bool wheelIsBlocked = false;
if ( IsOnGround )
{
float combinedWheelForce = motorForce + absCombinedBrakeTorque * invRadius * -Math.Sign( AngularVelocity );
float wheelForceClampOverflow = 0;
if ( (combinedWheelForce >= 0 && AngularVelocity < 0) || (combinedWheelForce < 0 && AngularVelocity > 0) )
{
float absWheelForceClamp = Math.Abs( AngularVelocity ) * inertia * invRadius * invDt;
float absCombinedWheelForce = combinedWheelForce < 0 ? -combinedWheelForce : combinedWheelForce;
float wheelForceDiff = absCombinedWheelForce - absWheelForceClamp;
wheelForceClampOverflow = Math.Max( 0, wheelForceDiff ) * Math.Sign( combinedWheelForce );
combinedWheelForce = Math.Clamp( combinedWheelForce, -absWheelForceClamp, absWheelForceClamp );
}
AngularVelocity += combinedWheelForce * mRadius * invInertia * dt;
// Surface (corrective) force
float noSlipAngularVelocity = ForwardFriction.Speed * invRadius;
float angularVelocityError = AngularVelocity - noSlipAngularVelocity;
float angularVelocityCorrectionForce = Math.Clamp( -angularVelocityError * inertia * invRadius * invDt, -maxForwardForce, maxForwardForce );
if ( absMotorTorque < absBrakeTorque && Math.Abs( wheelForceClampOverflow ) > Math.Abs( angularVelocityCorrectionForce ) )
{
wheelIsBlocked = true;
AngularVelocity += ForwardFriction.Speed > 0 ? 1e-10f : -1e-10f;
}
else
{
AngularVelocity += angularVelocityCorrectionForce * mRadius * invInertia * dt;
}
}
else
{
float maxBrakeTorque = AngularVelocity * inertia * invDt + motorTorque;
maxBrakeTorque = maxBrakeTorque < 0 ? -maxBrakeTorque : maxBrakeTorque;
float brakeTorqueSign = AngularVelocity < 0f ? -1f : 1f;
float clampedBrakeTorque = Math.Clamp( absCombinedBrakeTorque, -maxBrakeTorque, maxBrakeTorque );
AngularVelocity += (motorTorque - brakeTorqueSign * clampedBrakeTorque) * invInertia * dt;
}
float absAngularVelocity = AngularVelocity < 0 ? -AngularVelocity : AngularVelocity;
float maxCounterTorque = inertia * absAngularVelocity;
CounterTorque = Math.Clamp( (signedCombinedBrakeForce - ForwardFriction.Force) * mRadius, -maxCounterTorque, maxCounterTorque );
ForwardFriction.Slip = (ForwardFriction.Speed - AngularVelocity * mRadius) / clampedAbsForwardSpeed;
ForwardFriction.Slip *= slipLoadModifier;
SidewayFriction.Slip = MathF.Atan2( SidewayFriction.Speed, clampedAbsForwardSpeed );
SidewayFriction.Slip *= slipLoadModifier;
float sideSlipSign = SidewayFriction.Slip > 0 ? 1 : -1;
float absSideSlip = Math.Abs( SidewayFriction.Slip );
float peakSideFrictionForce = sideLoadFactor;
float sideForce = -sideSlipSign * Tire.Evaluate( absSideSlip ) * sideLoadFactor;
SidewayFriction.Force = Math.Clamp( sideForce, -sideForceClamp, sideForceClamp );
SidewayFriction.Force *= camberFrictionCoeff;
if ( IsOnGround && absForwardSpeed < 0.12f && absSideSpeed < 0.12f )
{
float verticalOffset = RestLength + mRadius;
var transformPosition = WorldPosition;
var transformUp = TransformRotationSteer.Up;
currentPosition.x = transformPosition.x - transformUp.x * verticalOffset;
currentPosition.y = transformPosition.y - transformUp.y * verticalOffset;
currentPosition.z = transformPosition.z - transformUp.z * verticalOffset;
if ( !lowSpeedReferenceIsSet )
{
lowSpeedReferenceIsSet = true;
lowSpeedReferencePosition = currentPosition;
}
else
{
referenceError.x = lowSpeedReferencePosition.x - currentPosition.x;
referenceError.y = lowSpeedReferencePosition.y - currentPosition.y;
referenceError.z = lowSpeedReferencePosition.z - currentPosition.z;
correctiveForce.x = invDt * LoadContribution * mass * referenceError.x;
correctiveForce.y = invDt * LoadContribution * mass * referenceError.y;
correctiveForce.z = invDt * LoadContribution * mass * referenceError.z;
if ( wheelIsBlocked && absAngularVelocity < 0.5f )
{
ForwardFriction.Force += correctiveForce.Dot( hitForwardDirection );
}
SidewayFriction.Force += correctiveForce.Dot( hitSidewaysDirection );
}
}
else
{
lowSpeedReferenceIsSet = false;
}
ForwardFriction.Force = Math.Clamp( ForwardFriction.Force, -peakForwardFrictionForce, peakForwardFrictionForce );
SidewayFriction.Force = Math.Clamp( SidewayFriction.Force, -peakSideFrictionForce, peakSideFrictionForce );
if ( absForwardSpeed > 2f || absAngularVelocity > 4f )
{
float forwardSlipPercent = ForwardFriction.Slip / Tire.GetPeakSlip();
float sideSlipPercent = SidewayFriction.Slip / Tire.GetPeakSlip();
float slipCircleLimit = MathF.Sqrt( forwardSlipPercent * forwardSlipPercent + sideSlipPercent * sideSlipPercent );
if ( slipCircleLimit > 1f )
{
float beta = MathF.Atan2( sideSlipPercent, forwardSlipPercent * 0.9f );
float sinBeta = MathF.Sin( beta );
float cosBeta = MathF.Cos( beta );
float absForwardForce = ForwardFriction.Force < 0 ? -ForwardFriction.Force : ForwardFriction.Force;
float absSideForce = SidewayFriction.Force < 0 ? -SidewayFriction.Force : SidewayFriction.Force;
float f = absForwardForce * cosBeta * cosBeta + absSideForce * sinBeta * sinBeta;
ForwardFriction.Force = 0.5f * ForwardFriction.Force - f * cosBeta;
SidewayFriction.Force = 0.5f * SidewayFriction.Force - f * sinBeta;
}
}
if ( IsOnGround )
{
FrictionForce.x = (hitSidewaysDirection.x * SidewayFriction.Force + hitForwardDirection.x * ForwardFriction.Force).MeterToInch();
FrictionForce.y = (hitSidewaysDirection.y * SidewayFriction.Force + hitForwardDirection.y * ForwardFriction.Force).MeterToInch();
FrictionForce.z = (hitSidewaysDirection.z * SidewayFriction.Force + hitForwardDirection.z * ForwardFriction.Force).MeterToInch();
//DebugOverlay.Normal( WorldPosition, hitSidewaysDirection * 10, overlay: true );
//DebugOverlay.Normal( WorldPosition, hitForwardDirection * 10, overlay: true );
//DebugOverlay.Normal( WorldPosition, FrictionForce / 100, overlay: true );
//DebugOverlay.Normal( ContactPosition, Vector3.Up * AngularVelocity, overlay: true );
//DebugOverlay.Sphere( new( ContactPosition, 4 ), overlay: true );
//Vehicle.Body.ApplyForceAt( ContactPosition, FrictionForce );
}
else
FrictionForce = Vector3.Zero;
}
}

View File

@ -5,7 +5,9 @@ namespace VeloX;
public partial class VeloXWheel : Component public partial class VeloXWheel : Component
{ {
protected override void DrawGizmos() protected override void DrawGizmos() => GizmoDraw();
public void GizmoDraw()
{ {
if ( !Gizmo.IsSelected ) if ( !Gizmo.IsSelected )
@ -18,7 +20,7 @@ public partial class VeloXWheel : Component
// //
{ {
var suspensionStart = Vector3.Zero; var suspensionStart = Vector3.Zero;
var suspensionEnd = Vector3.Zero + Vector3.Down * SuspensionLength; var suspensionEnd = Vector3.Zero + Vector3.Down * RestLength.MeterToInch();
Gizmo.Draw.Color = Color.Cyan; Gizmo.Draw.Color = Color.Cyan;
Gizmo.Draw.LineThickness = 0.25f; Gizmo.Draw.LineThickness = 0.25f;
@ -28,7 +30,7 @@ public partial class VeloXWheel : Component
Gizmo.Draw.Line( suspensionStart + Vector3.Forward, suspensionStart + Vector3.Backward ); Gizmo.Draw.Line( suspensionStart + Vector3.Forward, suspensionStart + Vector3.Backward );
Gizmo.Draw.Line( suspensionEnd + Vector3.Forward, suspensionEnd + Vector3.Backward ); Gizmo.Draw.Line( suspensionEnd + Vector3.Forward, suspensionEnd + Vector3.Backward );
} }
var widthOffset = Vector3.Right * Width * 0.5f; var widthOffset = Vector3.Right * Width.MeterToInch() * 0.5f;
// //
// Wheel radius // Wheel radius
// //
@ -36,7 +38,7 @@ public partial class VeloXWheel : Component
Gizmo.Draw.LineThickness = 0.5f; Gizmo.Draw.LineThickness = 0.5f;
Gizmo.Draw.Color = Color.White; Gizmo.Draw.Color = Color.White;
Gizmo.Draw.LineCylinder( widthOffset, -widthOffset, Radius, Radius, 16 ); Gizmo.Draw.LineCylinder( widthOffset, -widthOffset, Radius.MeterToInch(), Radius.MeterToInch(), 16 );
} }
// //
@ -51,24 +53,14 @@ public partial class VeloXWheel : Component
for ( float i = 0; i < 16; i++ ) for ( float i = 0; i < 16; i++ )
{ {
var pos = circlePosition + Vector3.Up.RotateAround( Vector3.Zero, new Angles( i / 16 * 360, 0, 0 ) ) * Radius; var pos = circlePosition + Vector3.Up.RotateAround( Vector3.Zero, new Angles( i / 16 * 360, 0, 0 ) ) * Radius.MeterToInch();
Gizmo.Draw.Line( new Line( pos - widthOffset, pos + widthOffset ) ); Gizmo.Draw.Line( new Line( pos - widthOffset, pos + widthOffset ) );
var pos2 = circlePosition + Vector3.Up.RotateAround( Vector3.Zero, new Angles( (i + 1) / 16 * 360, 0, 0 ) ) * Radius; var pos2 = circlePosition + Vector3.Up.RotateAround( Vector3.Zero, new Angles( (i + 1) / 16 * 360, 0, 0 ) ) * Radius.MeterToInch();
Gizmo.Draw.Line( pos - widthOffset, pos2 + widthOffset ); Gizmo.Draw.Line( pos - widthOffset, pos2 + widthOffset );
} }
} }
////
//// Forward direction
////
//{
// var arrowStart = Vector3.Forward * Radius;
// var arrowEnd = arrowStart + Vector3.Forward * 8f;
// Gizmo.Draw.Color = Color.Red;
// Gizmo.Draw.Arrow( arrowStart, arrowEnd, 4, 1 );
//}
} }
} }

View File

@ -0,0 +1,91 @@
using Sandbox;
using System.Collections.Generic;
namespace VeloX;
public partial class VeloXWheel
{
[ConCmd( "clear_skids" )]
public static void ClearSkids()
{
while ( SkidMarks.Count > 1 )
SkidMarks.Dequeue()?.DestroyGameObject();
}
[ConVar( "skidmark_max_skid" )]
public static float MaxSkid { get; set; } = 50.0f;
[ConVar( "skidmark_min_slide" )]
public static float MinSlide { get; set; } = 0.1f;
private static readonly Queue<LineRenderer> SkidMarks = [];
private LineRenderer _skidMark;
private void ResetSkid()
{
_skidMark = null;
}
private void CreateSkid()
{
GameObject go = new()
{
WorldPosition = ContactPosition + Vehicle.WorldRotation.Down * (Radius.MeterToInch() - 0.01f),
WorldRotation = Rotation.LookAt( hitSidewaysDirection )
};
_skidMark = go.AddComponent<LineRenderer>();
_skidMark.Face = SceneLineObject.FaceMode.Normal;
_skidMark.Points = [go];
_skidMark.Color = Color.Black.WithAlpha( 0.5f );
_skidMark.Opaque = false;
_skidMark.CastShadows = false;
_skidMark.Width = Width.MeterToInch() / 2;
_skidMark.AutoCalculateNormals = false;
_skidMark.SplineInterpolation = 4;
go.Flags = go.Flags.WithFlag( GameObjectFlags.Hidden, true );
go.Flags = go.Flags.WithFlag( GameObjectFlags.NotNetworked, true );
SkidMarks.Enqueue( _skidMark );
}
protected void UpdateSkid()
{
if ( IsProxy )
return;
while ( SkidMarks.Count > MaxSkid )
{
SkidMarks.Dequeue()?.DestroyGameObject();
}
if ( !IsOnGround )
{
ResetSkid();
return;
}
var slideAmount = NormalizedSlip;
if ( slideAmount < MinSlide * 2 )
{
ResetSkid();
return;
}
if ( !_skidMark.IsValid() )
{
CreateSkid();
}
GameObject go = new()
{
Parent = _skidMark.GameObject,
WorldPosition = ContactPosition + Vehicle.WorldRotation.Down * Radius.MeterToInch(),
WorldRotation = Rotation.LookAt( ContactNormal.RotateAround( Vector3.Zero, Rotation.FromRoll( 90 ) ) )
};
go.Flags = go.Flags.WithFlag( GameObjectFlags.Hidden, true );
go.Flags = go.Flags.WithFlag( GameObjectFlags.NotNetworked, true );
_skidMark.Points.Add( go );
}
}

View File

@ -0,0 +1,108 @@
using Sandbox;
using System;
using System.Collections.Generic;
using System.Threading;
namespace VeloX;
public partial class VeloXWheel
{
private static readonly GameObject SmokePrefab = GameObject.GetPrefab( "prefabs/particles/tire_smoke.prefab" );
private GameObject SmokeObject { get; set; }
public const float MIN_DRIFT_ANGLE = 10f;
public const float MIN_DRIFT_SPEED = 30f;
public const float MAX_DRIFT_ANGLE = 110f;
private float smokeMul;
private float timeMul;
protected override void OnStart()
{
base.OnStart();
if ( IsProxy )
return;
if ( !SmokeObject.IsValid() )
SmokeObject = GameObject.GetPrefab( "prefabs/particles/tire_smoke.prefab" ).Clone( new CloneConfig() { Parent = GameObject, StartEnabled = true } );
}
public float GetSlip()
{
if ( !IsOnGround )
return 0;
var val = Math.Abs( LateralSlip ) + Math.Abs( LongitudinalSlip );
timeMul = timeMul.LerpTo( val, 0.1f );
//if ( timeMul > 2 )
// return val;
return val * 5;
}
protected override void OnUpdate()
{
base.OnUpdate();
if ( IsProxy )
return;
UpdateSkid();
SmokeObject.WorldPosition = ContactPosition + Vehicle.WorldRotation.Down * Radius.MeterToInch();
smokeMul = Math.Max( 0, GetSlip() - 3 );
bool density = false;
if ( smokeMul > 0 )
density = true;
var emitter = SmokeObject.Components.Get<ParticleSphereEmitter>( FindMode.EverythingInSelfAndDescendants );
emitter.Enabled = density;
emitter.Radius = 0f;
emitter.Velocity = 0;
emitter.Duration = 5;
emitter.Burst = 5;
float sizeMul = 0.7f + smokeMul * 0.3f;
emitter.Rate = smokeMul * 100f;
emitter.RateOverDistance = smokeMul * 10f;
var effect = SmokeObject.Components.Get<ParticleEffect>( FindMode.EverythingInSelfAndDescendants );
effect.MaxParticles = 500;
effect.Damping = 0.9f;
effect.ApplyRotation = true;
effect.ApplyShape = true;
effect.Roll = new()
{
Type = ParticleFloat.ValueType.Range,
Evaluation = ParticleFloat.EvaluationType.Seed,
ConstantA = 0,
ConstantB = 360,
};
effect.Scale = new()
{
Type = ParticleFloat.ValueType.Curve,
Evaluation = ParticleFloat.EvaluationType.Life,
CurveA = new( new List<Curve.Frame>() { new( 0, 10f ), new( 0.8f, 50f ), new( 1f, 160f ) } ),
};
effect.StartDelay = 0.025f + (1 - smokeMul) * 0.03f;
effect.ApplyColor = true;
effect.Gradient = new()
{
Type = ParticleGradient.ValueType.Range,
Evaluation = ParticleGradient.EvaluationType.Particle,
ConstantA = Color.White,
ConstantB = Color.Transparent,
};
effect.StartVelocity = new()
{
Type = ParticleFloat.ValueType.Range,
Evaluation = ParticleFloat.EvaluationType.Seed,
ConstantA = 10,
ConstantB = 70,
};
effect.Force = true;
effect.InitialVelocity = Vehicle.Body.Velocity / 3 + hitForwardDirection * LongitudinalSlip * 10f;
effect.ForceDirection = 0;
effect.SheetSequence = true;
effect.SequenceSpeed = 0.5f;
effect.SequenceTime = 1f;
}
}

View File

@ -1,16 +1,5 @@
using Sandbox; using Sandbox;
using Sandbox.Rendering;
using Sandbox.Services;
using Sandbox.UI;
using System; using System;
using System.Collections.Specialized;
using System.Diagnostics.Metrics;
using System.Numerics;
using System.Text.RegularExpressions;
using System.Threading;
using static Sandbox.CameraComponent;
using static Sandbox.Package;
using static Sandbox.SkinnedModelRenderer;
namespace VeloX; namespace VeloX;
@ -18,72 +7,56 @@ namespace VeloX;
[Title( "VeloX - Wheel" )] [Title( "VeloX - Wheel" )]
public partial class VeloXWheel : Component public partial class VeloXWheel : Component
{ {
[Property, Group( "Suspension" )] public float Radius { get; set; } = 0.35f;
[Property, Group( "Suspension" )] public float Width { get; set; } = 0.1f;
[Property] public float Mass { get; set; } = 5;
[Property, Group( "Suspension" )] float RestLength { get; set; } = 0.22f;
[Property, Group( "Suspension" )] public float SpringStiffness { get; set; } = 20000.0f;
[Property, Group( "Suspension" )] float ReboundStiffness { get; set; } = 2200;
[Property, Group( "Suspension" )] float CompressionStiffness { get; set; } = 2400;
[Property, Group( "Suspension" )] float BumpStopStiffness { get; set; } = 5000;
[Property] public float Radius { get; set; } = 15; [Property, Group( "Traction" )] public TirePreset Tire { get; set; } = ResourceLibrary.Get<TirePreset>( "frictions/default.tire" );
[Property] public float Mass { get; set; } = 20; [Property, Group( "Traction" )] public float SurfaceGrip { get; set; } = 1f;
[Property] public float RollingResistance { get; set; } = 20; [Property, Group( "Traction" )] public float SurfaceResistance { get; set; } = 0.05f;
[Property] public float SlipCircleShape { get; set; } = 1.05f; public bool AutoSimulate = true;
[Property] public TirePreset TirePreset { get; set; }
[Property] public float Width { get; set; } = 6;
public float SideSlip { get; private set; } public float BaseInertia => Mass * (Radius * Radius); // kg·m²
public float ForwardSlip { get; private set; } [Property] public float Inertia { get; set; } = 1.5f; // kg·m²
[Sync] public float Torque { get; set; }
[Sync, Range( 0, 1 )] public float Brake { get; set; }
[Property] float BrakePowerMax { get; set; } = 3000;
[Property] public bool IsFront { get; protected set; } [Property] public bool IsFront { get; protected set; }
[Property] public float SteerMultiplier { get; set; } [Property] public float SteerMultiplier { get; set; }
[Property] public float CasterAngle { get; set; } = 7; // todo
[Property] public float CamberAngle { get; set; } = -3;
[Property] public float ToeAngle { get; set; } = 0.5f;
[Property] public float Ackermann { get; set; } = 0;
[Property, Group( "Suspension" )] float SuspensionLength { get; set; } = 10; public float RPM { get => AngularVelocity * 60f / MathF.Tau; set => AngularVelocity = value / (60 / MathF.Tau); }
[Property, Group( "Suspension" )] float SpringStrength { get; set; } = 800;
[Property, Group( "Suspension" )] float SpringDamper { get; set; } = 3000;
[Property] public bool AutoPhysics { get; set; } = true;
public float Spin { get; private set; }
public float RPM { get => angularVelocity * 30f / MathF.PI; set => angularVelocity = value / (30f / MathF.PI); }
public float AngularVelocity { get => angularVelocity; set => angularVelocity = value; }
internal float DistributionFactor { get; set; }
private Vector3 StartPos { get; set; } private Vector3 StartPos { get; set; }
private static Rotation CylinderOffset => Rotation.FromRoll( 90 ); private static Rotation CylinderOffset = Rotation.FromRoll( 90 );
public SceneTraceResult Trace { get; private set; } [Sync] public bool IsOnGround { get; private set; }
public bool IsOnGround => Trace.Hit;
private float lastSpringOffset;
private float angularVelocity;
private float load;
private float lastFraction;
private Vector3 contactPos; [Property] public float DriveTorque { get; set; }
private Vector3 forward; [Property] public float BrakeTorque { get; set; }
private Vector3 right;
private Vector3 up;
private float forwardFriction; public float Compression { get; protected set; } // meters
private float sideFriction; public float LastLength { get; protected set; } // meters
private Vector3 force; public float Fz { get; protected set; } // N
public float CounterTorque { get; private set; } public float AngularVelocity { get; protected set; } // rad/s
public float RollAngle { get; protected set; } // degrees
internal float BaseInertia => 0.5f * Mass * MathF.Pow( Radius.InchToMeter(), 2 );
public float Inertia
{
get => BaseInertia + inertia;
set => inertia = value;
}
private VeloXBase Vehicle;
[Sync] public Vector3 ContactNormal { get; protected set; }
[Sync] public Vector3 ContactPosition { get; protected set; }
Rotation TransformRotationSteer => Vehicle.WorldTransform.RotationToWorld( Vehicle.SteerAngle * SteerMultiplier );
protected override void OnAwake() protected override void OnAwake()
{ {
Vehicle = Components.Get<VeloXBase>( FindMode.EverythingInSelfAndAncestors );
base.OnAwake(); base.OnAwake();
if ( StartPos.IsNearZeroLength ) if ( StartPos.IsNearZeroLength )
StartPos = LocalPosition; StartPos = LocalPosition;
Inertia = BaseInertia;
} }
internal void Update( VeloXBase vehicle, in float dt ) internal void Update( VeloXBase vehicle, in float dt )
@ -93,224 +66,159 @@ public partial class VeloXWheel : Component
private void UpdateVisuals( VeloXBase vehicle, in float dt ) private void UpdateVisuals( VeloXBase vehicle, in float dt )
{ {
Spin -= angularVelocity.RadianToDegree() * dt; WorldRotation = vehicle.WorldTransform.RotationToWorld( vehicle.SteerAngle * SteerMultiplier ).RotateAroundAxis( Vector3.Right, -RollAngle );
WorldRotation = vehicle.WorldTransform.RotationToWorld( GetSteer( vehicle.SteerAngle.yaw ) ) * Rotation.FromAxis( Vector3.Right, Spin ); LocalPosition = StartPos + Vector3.Down * LastLength.MeterToInch();
} }
private Rotation GetSteer( float steer ) private struct WheelTraceData
{
internal Vector3 ContactNormal;
internal Vector3 ContactPosition;
internal float Compression;
internal float Force;
}
public void UpdateForce()
{
Vehicle.Body.ApplyForceAt( ContactPosition, FrictionForce + ContactNormal * Fz.MeterToInch() );
}
internal void StepPhys( VeloXBase vehicle, in float dt )
{ {
float angle = (-steer * SteerMultiplier).DegreeToRadian(); const int numSamples = 3;
float halfWidth = Width.MeterToInch() * 0.5f;
float t = MathF.Tan( (MathF.PI / 2) - angle ) - Ackermann; int hitCount = 0;
float steering_angle = MathF.CopySign( float.Pi / 2, t ) - MathF.Atan( t ); WheelTraceData wheelTraceData = new();
var steering_axis = Vector3.Up * MathF.Cos( -CasterAngle.DegreeToRadian() ) + for ( int i = 0; i < numSamples; i++ )
Vector3.Right * MathF.Sin( -CasterAngle.DegreeToRadian() ); {
float t = (float)i / (numSamples - 1);
return Rotation.FromAxis( Vector3.Forward, -CamberAngle ) * Rotation.FromAxis( steering_axis, steering_angle.RadianToDegree() ); float offset = MathX.Lerp( -halfWidth, halfWidth, t );
Vector3 start = vehicle.WorldTransform.PointToWorld( StartPos + Vector3.Right * offset );
Vector3 end = start + vehicle.WorldRotation.Down * RestLength.MeterToInch();
if ( TraceWheel( vehicle, ref wheelTraceData, start, end, Width.MeterToInch() / numSamples, dt ) )
hitCount++;
} }
private static float GetLongitudinalLoadCoefficient( float load ) => 11000 * (1 - MathF.Exp( -0.00014f * load )); if ( hitCount > 0 )
private static float GetLateralLoadCoefficient( float load ) => 18000 * (1 - MathF.Exp( -0.0001f * load )); {
private float inertia;
IsOnGround = true;
private (float, float, float, float) StepLongitudinal( float Tm, float Tb, float Vx, float W, float Lc, float R, float I ) Fz = Math.Max( wheelTraceData.Force / hitCount, 0 );
{ Compression = wheelTraceData.Compression / hitCount;
float wInit = W; ContactNormal = (wheelTraceData.ContactNormal / hitCount).Normal;
float vxAbs = Math.Abs( Vx ); ContactPosition = wheelTraceData.ContactPosition / hitCount;
float Sx; //DoSuspensionSounds( vehicle, (RestLength - Compression) * 0.8f);
if ( Lc < 0.01f ) LastLength = RestLength - Compression;
{
Sx = 0; UpdateHitVariables();
} UpdateFriction( dt );
else if ( vxAbs >= 0.01f )
{
Sx = (W * R - Vx) / vxAbs;
} }
else else
{ {
Sx = (W * R - Vx) * 0.6f; IsOnGround = false;
// Wheel is off the ground
Compression = 0f;
Fz = 0f;
ContactNormal = Vector3.Up;
ContactPosition = WorldPosition;
} }
Sx = Math.Clamp( Sx, -1, 1 );
W += Tm / I * Time.Delta;
Tb *= W > 0 ? -1 : 1;
float tbCap = Math.Abs( W ) * I / Time.Delta;
float tbr = Math.Abs( Tb ) - Math.Abs( tbCap );
tbr = Math.Max( tbr, 0 );
Tb = Math.Clamp( Tb, -tbCap, tbCap );
W += Tb / I * Time.Delta;
float maxTorque = TirePreset.Pacejka.PacejkaFx( Math.Abs( Sx ) ) * Lc * R;
float errorTorque = (W - Vx / R) * I / Time.Delta;
float surfaceTorque = Math.Clamp( errorTorque, -maxTorque, maxTorque );
W -= surfaceTorque / I * Time.Delta;
float Fx = surfaceTorque / R;
tbr *= (W > 0 ? -1 : 1);
float TbCap2 = Math.Abs( W ) * I / Time.Delta;
float Tb2 = Math.Clamp( tbr, -TbCap2, TbCap2 );
W += Tb2 / I * Time.Delta;
float deltaOmegaTorque = (W - wInit) * I / Time.Delta;
float Tcnt = -surfaceTorque + Tb + Tb2 - deltaOmegaTorque;
return (W, Sx, Fx, Tcnt);
} }
private void StepLateral( float Vx, float Vy, float Lc, out float Sy, out float Fy ) const string playerTag = "player";
{
float VxAbs = Math.Abs( Vx );
if ( Lc < 0.01f ) private bool TraceWheel( VeloXBase vehicle, ref WheelTraceData wheelTraceData, Vector3 start, Vector3 end, float width, in float dt )
{
Sy = 0;
}
else if ( VxAbs > 0.1f )
{ {
Sy = MathX.RadianToDegree( MathF.Atan( Vy / VxAbs ) ) / 50; var trace = Scene.Trace
} .FromTo( start, end )
else .Cylinder( width, Radius.MeterToInch() )
{ .Rotated( vehicle.WorldRotation * CylinderOffset )
.UseHitPosition( false )
Sy = Vy * (0.003f / Time.Delta); .IgnoreGameObjectHierarchy( Vehicle.GameObject )
} .WithoutTags( playerTag )
Sy = Math.Clamp( Sy, -1, 1 );
float slipSign = Sy < 0 ? -1 : 1;
Fy = -slipSign * TirePreset.Pacejka.PacejkaFy( Math.Abs( Sy ) ) * Lc;
}
private void SlipCircle( float Sx, float Sy, float Fx, ref float Fy )
{
float SxAbs = Math.Abs( Sx );
if ( SxAbs > 0.01f )
{
float SxClamped = Math.Clamp( Sx, -1, 1 );
float SyClamped = Math.Clamp( Sy, -1, 1 );
Vector2 combinedSlip = new( SxClamped * 1.05f, SyClamped );
Vector2 slipDir = combinedSlip.Normal;
float F = MathF.Sqrt( Fx * Fx + Fy * Fy );
float absSlipDirY = Math.Abs( slipDir.y );
Fy = F * absSlipDirY * (Fy < 0 ? -1 : 1);
}
}
public void DoPhysics( VeloXBase vehicle )
{
var pos = vehicle.WorldTransform.PointToWorld( StartPos );
var ang = vehicle.WorldTransform.RotationToWorld( GetSteer( vehicle.SteerAngle.yaw ) );
var maxLen = SuspensionLength;
var endPos = pos + ang.Down * maxLen;
Trace = Scene.Trace
.IgnoreGameObjectHierarchy( vehicle.GameObject )
.Cylinder( Width, Radius, pos, endPos )
.Rotated( vehicle.WorldTransform.Rotation * CylinderOffset )
.UseRenderMeshes( false )
.UseHitPosition( true )
.WithoutTags( vehicle.WheelIgnoredTags )
.Run(); .Run();
forward = Vector3.VectorPlaneProject( ang.Forward, Trace.Normal ); //DebugOverlay.Trace( trace, overlay: true );
right = Vector3.VectorPlaneProject( ang.Right, Trace.Normal ); if ( trace.Hit )
{
var fraction = Trace.Fraction; Vector3 contactPos = trace.EndPosition;
Vector3 contactNormal = trace.Normal;
float currentLength = trace.Distance.InchToMeter();
float compression = (RestLength - currentLength).Clamp( -RestLength, RestLength );
contactPos = pos - maxLen * fraction * ang.Up; // Nonlinear spring
float springForce = SpringStiffness * compression * (1f + 2f * compression / RestLength);
LocalPosition = vehicle.WorldTransform.PointToLocal( contactPos ); // Damping
float damperVelocity = (LastLength - currentLength) / dt;
float damperForce = damperVelocity > 0 ? damperVelocity * ReboundStiffness : damperVelocity * CompressionStiffness;
DoSuspensionSounds( vehicle, fraction - lastFraction ); float FzPoint = springForce + damperForce;
lastFraction = fraction;
if ( !IsOnGround ) // Bump stop
float minCompression = -0.05f;
if ( compression <= minCompression ) FzPoint += (minCompression - compression) * BumpStopStiffness;
wheelTraceData.ContactNormal += contactNormal;
wheelTraceData.ContactPosition += contactPos;
wheelTraceData.Compression += compression;
wheelTraceData.Force += Math.Max( 0, FzPoint );
return true;
}
return false;
}
public void DoPhysics( in float dt )
{
if ( IsProxy )
return; return;
if ( AutoSimulate )
StepPhys( Vehicle, dt );
StepRotation( Vehicle, dt );
}
var vel = vehicle.Body.GetVelocityAtPoint( contactPos ); const float HubCoulombNm = 20f;
const float HubViscous = 0.1f;
var offset = maxLen - (fraction * maxLen); private void StepRotation( VeloXBase vehicle, in float dt )
var springForce = offset * SpringStrength;
var damperForce = (lastSpringOffset - offset) * SpringDamper;
lastSpringOffset = offset;
var velU = Trace.Normal.Dot( vel );
if ( velU < 0 && offset + Math.Abs( velU * Time.Delta ) > SuspensionLength )
{ {
vehicle.Body.CalculateVelocityOffset( -velU / Time.Delta * Trace.Normal, pos, out var linearImp, out var angularImp ); float inertia = MathF.Max( 1f, Inertia );
float roadTorque = ForwardFriction.Speed * Radius;
float externalTorque = DriveTorque - roadTorque;
float rollingResistanceTorque = Fz * Radius * SurfaceResistance;
vehicle.Body.Velocity += linearImp; float coulombTorque = BrakeTorque + rollingResistanceTorque + HubCoulombNm;
vehicle.Body.AngularVelocity += angularImp;
vehicle.Body.CalculateVelocityOffset( Trace.HitPosition - (contactPos + Trace.Normal * velU * Time.Delta), pos, out var lin, out _ );
vehicle.WorldPosition += lin / Time.Delta; float omega = AngularVelocity;
damperForce = 0;
} if ( MathF.Abs( omega ) < 1e-6f && MathF.Abs( externalTorque ) <= coulombTorque )
force = (springForce - damperForce) * Trace.Normal;
load = Math.Max( springForce - damperForce, 0 );
float R = Radius.InchToMeter();
float forwardSpeed = vel.Dot( forward ).InchToMeter();
float sideSpeed = vel.Dot( right ).InchToMeter();
float longitudinalLoadCoefficient = GetLongitudinalLoadCoefficient( load );
float lateralLoadCoefficient = GetLateralLoadCoefficient( load );
float F_roll = TirePreset.GetRollingResistance( angularVelocity * R, 1.0f ) * 10000;
( float W, float Sx, float Fx, float counterTq) = StepLongitudinal(
Torque,
Brake * BrakePowerMax + F_roll,
forwardSpeed,
angularVelocity,
longitudinalLoadCoefficient,
R,
Inertia
);
StepLateral( forwardSpeed, sideSpeed, lateralLoadCoefficient, out float Sy, out float Fy );
SlipCircle( Sx, Sy, Fx, ref Fy );
CounterTorque = counterTq;
angularVelocity = W;
force += forward * Fx;
force += right * Fy * Math.Clamp( vehicle.TotalSpeed * 0.005f, 0, 1 );
ForwardSlip = Sx;
SideSlip = Sy * Math.Clamp( vehicle.TotalSpeed * 0.005f, 0, 1 );
vehicle.Body.ApplyForceAt( pos, force / Time.Delta );
}
#if DEBUG
protected override void OnUpdate()
{ {
DebugOverlay.Normal( contactPos, forward * forwardFriction, Color.Red, overlay: true ); AngularVelocity = 0f;
DebugOverlay.Normal( contactPos, right * sideFriction, Color.Green, overlay: true );
DebugOverlay.Normal( contactPos, up * force / 1000f, Color.Blue, overlay: true );
} }
#endif else
{
if ( HubViscous > 0f ) omega *= MathF.Exp( -(HubViscous / inertia) * dt );
if ( coulombTorque > 0f && omega != 0f )
{
float dir = MathF.Sign( omega );
float deltaOmega = (coulombTorque / inertia) * dt;
if ( deltaOmega >= MathF.Abs( omega ) ) omega = 0f;
else omega -= dir * deltaOmega;
}
if ( MathF.Abs( omega ) < 0.01f ) omega = 0f;
}
AngularVelocity = omega;
RollAngle += MathX.RadianToDegree( AngularVelocity ) * dt;
RollAngle = (RollAngle % 360f + 360f) % 360f;
}
} }

View File

@ -0,0 +1,58 @@
using Sandbox;
using System;
using System.Diagnostics;
using System.Linq;
namespace VeloX;
internal sealed class WheelManager : GameObjectSystem
{
public WheelManager( Scene scene ) : base( scene )
{
Listen( Stage.StartFixedUpdate, -99, UpdateWheels, "UpdateWheels" );
Listen( Stage.StartFixedUpdate, -100, UpdateEngine, "UpdateEngine" );
}
private void UpdateWheels()
{
if ( !Game.IsPlaying )
return;
//Stopwatch sw = Stopwatch.StartNew();
var wheels = Scene.GetAll<VeloXWheel>();
if ( !wheels.Any() ) return;
var timeDelta = Time.Delta;
Sandbox.Utility.Parallel.ForEach( wheels, item =>
{
if ( !item.IsProxy )
item.DoPhysics( timeDelta );
} );
foreach ( var wheel in wheels )
wheel.UpdateForce();
//sw.Stop();
//DebugOverlaySystem.Current.ScreenText( new Vector2( 120, 30 ), $"Wheel Sim: {sw.Elapsed.TotalMilliseconds,6:F2} ms", 24 );
}
private void UpdateEngine()
{
if ( !Game.IsPlaying )
return;
//Stopwatch sw = Stopwatch.StartNew();
var engines = Scene.GetAll<Engine>();
if ( !engines.Any() ) return;
var timeDelta = Time.Delta;
Sandbox.Utility.Parallel.ForEach( engines, item =>
{
if ( !item.IsProxy )
item.UpdateEngine( timeDelta );
} );
//sw.Stop();
//DebugOverlaySystem.Current.ScreenText( new Vector2( 120, 54 ), $"Engine Sim: {sw.Elapsed.TotalMilliseconds,6:F2} ms", 24 );
}
}

View File

@ -0,0 +1,60 @@
using Sandbox;
using System;
using System.Collections.Generic;
namespace VeloX;
public partial class VeloXCar
{
private class DownforcePoint
{
[KeyProperty] public float MaxForce { get; set; }
[KeyProperty] public Vector3 Position { get; set; }
}
public const float RHO = 1.225f;
[Property, Feature( "Aerodinamics" )] public Vector3 Dimensions = new( 2f, 4.5f, 1.5f );
[Property, Feature( "Aerodinamics" )] public float FrontalCd { get; set; } = 0.35f;
[Property, Feature( "Aerodinamics" )] public float SideCd { get; set; } = 1.05f;
[Property, Feature( "Aerodinamics" )] public float MaxDownforceSpeed { get; set; } = 80f;
[Property, Feature( "Aerodinamics" )] private List<DownforcePoint> DownforcePoints { get; set; } = [];
private float _forwardSpeed;
private float _frontalArea;
private float _sideArea;
private float _sideSpeed;
private float lateralDragForce;
private float longitudinalDragForce;
private void SimulateAerodinamics( float dt )
{
if ( TotalSpeed < 1f )
{
longitudinalDragForce = 0;
lateralDragForce = 0;
return;
}
_frontalArea = Dimensions.x * Dimensions.z * 0.85f;
_sideArea = Dimensions.y * Dimensions.z * 0.8f;
_forwardSpeed = LocalVelocity.x.InchToMeter();
_sideSpeed = LocalVelocity.y.InchToMeter();
longitudinalDragForce = 0.5f * RHO * _frontalArea * FrontalCd * (_forwardSpeed * _forwardSpeed) * (_forwardSpeed > 0 ? -1f : 1f);
lateralDragForce = 0.5f * RHO * _sideArea * SideCd * (_sideSpeed * _sideSpeed) * (_sideSpeed > 0 ? -1f : 1f);
var force = new Vector3( longitudinalDragForce.MeterToInch(), lateralDragForce.MeterToInch(), 0 ).RotateAround( Vector3.Zero, WorldRotation );
Body.ApplyForce( force );
//DebugOverlay.Normal( WorldPosition, force );
float speedPercent = TotalSpeed / MaxDownforceSpeed;
float forceCoeff = 1f - (1f - MathF.Pow( speedPercent, 2f ));
foreach ( DownforcePoint dp in DownforcePoints )
Body.ApplyForceAt( Transform.World.PointToWorld( dp.Position ), forceCoeff.MeterToInch() * dp.MaxForce.MeterToInch() * -WorldRotation.Up );
}
//protected override void DrawGizmos()
//{
// Gizmo.Draw.LineBBox( new BBox( -Dimensions / 2 * 39.37f, Dimensions / 2 * 39.37f ) );
//}
}

View File

@ -0,0 +1,70 @@

using Sandbox;
using System;
namespace VeloX;
public partial class VeloXCar
{
public const float MIN_DRIFT_ANGLE = 10f;
public const float MIN_DRIFT_SPEED = 10f;
public const float MAX_DRIFT_ANGLE = 110f;
public static readonly SoundFile SkidSound = SoundFile.Load( "sounds/tire/skid.wav" );
private SoundHandle _skidHandle;
private float targetPitch;
private float targetVolume;
public float GetDriftAngle()
{
if ( !IsOnGround )
return 0;
var velocity = Body.Velocity;
var forward = WorldRotation.Forward;
// Early exit if speed is too low
if ( TotalSpeed < MIN_DRIFT_SPEED )
return 0f;
// Normalize the dot product calculation
float dotProduct = velocity.Normal.Dot( forward );
// Handle potential floating point precision issues
float cosAngle = dotProduct;
cosAngle = MathX.Clamp( cosAngle, -1f, 1f );
// Calculate angle in degrees
float angle = MathF.Abs( MathX.RadianToDegree( MathF.Acos( cosAngle ) ) );
// Check if angle is within drift range
if ( angle >= MIN_DRIFT_ANGLE && angle <= MAX_DRIFT_ANGLE )
return angle;
return 0f;
}
protected virtual void UpdateDrift( float dt )
{
float driftAngle = GetDriftAngle();
float mul = (driftAngle - MIN_DRIFT_ANGLE) / (90 - MIN_DRIFT_ANGLE);
if ( !_skidHandle.IsValid() )
_skidHandle = Sound.PlayFile( SkidSound );
if ( !_skidHandle.IsValid() )
return;
targetVolume = mul;
targetPitch = 0.75f + 0.25f * mul;
_skidHandle.Pitch += (targetPitch - _skidHandle.Pitch) * dt * 5f;
_skidHandle.Volume += (targetVolume - _skidHandle.Volume) * dt * 10f;
_skidHandle.Position = WorldPosition;
}
}

View File

@ -1,365 +0,0 @@
using Sandbox;
using System;
using System.Collections.Generic;
using VeloX.Powertrain;
namespace VeloX;
public partial class VeloXCar
{
[Property, Feature( "Engine" )] Engine Engine { get; set; }
private void EngineThink( float dt )
{
Engine.Throttle = Input.Down( "Forward" ) ? 1 : 0;
Engine.ForwardStep( 0, 0 );
}
//[Property, Feature( "Engine" ), Sync] public EngineState EngineState { get; set; }
//[Property, Feature( "Engine" )] public EngineStream Stream { get; set; }
//public EngineStreamPlayer StreamPlayer { get; set; }
//[Property, Feature( "Engine" )] public float MinRPM { get; set; } = 800;
//[Property, Feature( "Engine" )] public float MaxRPM { get; set; } = 7000;
//[Property, Feature( "Engine" ), Range( -1, 1 )]
//public float PowerDistribution
//{
// get => powerDistribution; set
// {
// powerDistribution = value;
// UpdatePowerDistribution();
// }
//}
//[Property, Feature( "Engine" )] public float FlyWheelMass { get; set; } = 80f;
//[Property, Feature( "Engine" )] public float FlyWheelRadius { get; set; } = 0.5f;
//[Property, Feature( "Engine" )] public float FlywheelFriction { get; set; } = -6000;
//[Property, Feature( "Engine" )] public float FlywheelTorque { get; set; } = 20000;
//[Property, Feature( "Engine" )] public float EngineBrakeTorque { get; set; } = 2000;
//[Property, Feature( "Engine" )]
//public Dictionary<int, float> Gears { get; set; } = new()
//{
// [-1] = 2.5f,
// [0] = 0f,
// [1] = 2.8f,
// [2] = 1.7f,
// [3] = 1.2f,
// [4] = 0.9f,
// [5] = 0.75f,
// [6] = 0.7f
//};
//[Property, Feature( "Engine" )] public float DifferentialRatio { get; set; } = 1f;
//[Property, Feature( "Engine" ), Range( 0, 1 )] public float TransmissionEfficiency { get; set; } = 0.8f;
//[Property, Feature( "Engine" )] private float MinRPMTorque { get; set; } = 5000f;
//[Property, Feature( "Engine" )] private float MaxRPMTorque { get; set; } = 8000f;
//[Sync] public int Gear { get; set; } = 0;
//[Sync] public float Clutch { get; set; } = 1;
//[Sync( SyncFlags.Interpolate )] public float EngineRPM { get; set; }
//public float RPMPercent => (EngineRPM - MinRPM) / MaxRPM;
//private const float TAU = MathF.Tau;
//private int MinGear { get; set; }
//private int MaxGear { get; set; }
//[Sync] public bool IsRedlining { get; private set; }
//private float flywheelVelocity;
//private TimeUntil switchCD = 0;
//private float groundedCount;
//private float burnout;
//private float frontBrake;
//private float rearBrake;
//private float availableFrontTorque;
//private float availableRearTorque;
//private float avgSideSlip;
//private float avgPoweredRPM;
//private float avgForwardSlip;
//private float inputThrottle, inputBrake;
//private bool inputHandbrake;
//private float transmissionRPM;
//private float powerDistribution;
//public float FlywheelRPM
//{
// get => flywheelVelocity * 60 / TAU;
// set
// {
// flywheelVelocity = value * TAU / 60; EngineRPM = value;
// }
//}
//private void UpdateGearList()
//{
// int minGear = 0;
// int maxGear = 0;
// foreach ( var (gear, ratio) in Gears )
// {
// if ( gear < minGear )
// minGear = gear;
// if ( gear > maxGear )
// maxGear = gear;
// if ( minGear != 0 || maxGear != 0 )
// {
// SwitchGear( 0, false );
// }
// }
// MinGear = minGear;
// MaxGear = maxGear;
//}
//public void SwitchGear( int index, bool cooldown = true )
//{
// if ( Gear == index ) return;
// index = Math.Clamp( index, MinGear, MaxGear );
// if ( index == 0 || !cooldown )
// switchCD = 0;
// else
// switchCD = 0.3f;
// Clutch = 1;
// Gear = index;
//}
//public float TransmissionToEngineRPM( int gear ) => avgPoweredRPM * Gears[gear] * DifferentialRatio * 60 / TAU;
//public float GetTransmissionMaxRPM( int gear ) => FlywheelRPM / Gears[gear] / DifferentialRatio;
//private void UpdatePowerDistribution()
//{
// if ( Wheels is null ) return;
// int frontCount = 0, rearCount = 0;
// foreach ( var wheel in Wheels )
// {
// if ( wheel.IsFront )
// frontCount++;
// else
// rearCount++;
// }
// float frontDistribution = 0.5f + PowerDistribution * 0.5f;
// float rearDistribution = 1 - frontDistribution;
// frontDistribution /= frontCount;
// rearDistribution /= rearCount;
// foreach ( var wheel in Wheels )
// if ( wheel.IsFront )
// wheel.DistributionFactor = frontDistribution;
// else
// wheel.DistributionFactor = rearDistribution;
//}
//private void EngineAccelerate( float torque, float dt )
//{
// var inertia = 0.5f * FlyWheelMass * FlyWheelRadius * FlyWheelRadius;
// var angularAcceleration = torque / inertia;
// flywheelVelocity += angularAcceleration * dt;
//}
//private float GetTransmissionTorque( int gear, float minTorque, float maxTorque )
//{
// var torque = FlywheelRPM.Remap( MinRPM, MaxRPM, minTorque, maxTorque, true );
// torque *= (1 - Clutch);
// torque = torque * Gears[gear] * DifferentialRatio * TransmissionEfficiency;
// return gear == -1 ? -torque : torque;
//}
//private void AutoGearSwitch()
//{
// if ( ForwardSpeed < 100 && Input.Down( "Backward" ) )
// {
// SwitchGear( -1, false );
// return;
// }
// var currentGear = Gear;
// if ( currentGear < 0 && ForwardSpeed < -100 )
// return;
// if ( Math.Abs( avgForwardSlip ) > 10 )
// return;
// var gear = Math.Clamp( currentGear, 1, MaxGear );
// float minRPM = MinRPM, maxRPM = MaxRPM;
// maxRPM *= 0.98f;
// float gearRPM;
// for ( int i = 1; i <= MaxGear; i++ )
// {
// gearRPM = TransmissionToEngineRPM( i );
// if ( (i == 1 && gearRPM < minRPM) || (gearRPM > minRPM && gearRPM < maxRPM) )
// {
// gear = i;
// break;
// }
// }
// var threshold = minRPM + (maxRPM - minRPM) * (0.5 - Throttle * 0.3);
// if ( gear < currentGear && gear > currentGear - 2 && EngineRPM > threshold )
// return;
// SwitchGear( gear );
//}
//private float EngineClutch( float dt )
//{
// if ( !switchCD )
// {
// inputThrottle = 0;
// return 0;
// }
// if ( inputHandbrake )
// return 1;
// var absForwardSpeed = Math.Abs( ForwardSpeed );
// if ( groundedCount < 1 && absForwardSpeed > 30 )
// return 1;
// if ( ForwardSpeed < -50 && inputBrake > 0 && Gear < 0 )
// return 1;
// if ( absForwardSpeed > 200 )
// return 0;
// return inputThrottle > 0.1f ? 0 : 1;
//}
//private void EngineThink( float dt )
//{
// inputThrottle = Input.Down( "Forward" ) ? 1 : 0;
// inputBrake = Input.Down( "Backward" ) ? 1 : 0;
// inputHandbrake = Input.Down( "Jump" );
// if ( burnout > 0 )
// {
// SwitchGear( 1, false );
// if ( inputThrottle < 0.1f || inputBrake < 0.1f )
// burnout = 0;
// }
// else
// AutoGearSwitch();
// if ( Gear < 0 )
// (inputBrake, inputThrottle) = (inputThrottle, inputBrake);
// var rpm = FlywheelRPM;
// var clutch = EngineClutch( dt );
// if ( inputThrottle > 0.1 && inputBrake > 0.1 && Math.Abs( ForwardSpeed ) < 50 )
// {
// burnout = MathX.Approach( burnout, 1, dt * 2 );
// Clutch = 0;
// }
// else if ( inputHandbrake )
// {
// frontBrake = 0f;
// rearBrake = 0.5f;
// Clutch = 1;
// clutch = 1;
// }
// else
// {
// if ( (Gear == -1 || Gear == 1) && inputThrottle < 0.05f && inputBrake < 0.1f && groundedCount > 1 && rpm < MinRPM * 1.2f )
// inputBrake = 0.2f;
// frontBrake = inputBrake * 0.5f;
// rearBrake = inputBrake * 0.5f;
// }
// clutch = MathX.Approach( Clutch, clutch, dt * ((Gear < 2 && inputThrottle > 0.1f) ? 6 : 2) );
// Clutch = clutch;
// var isRedlining = false;
// transmissionRPM = 0;
// if ( Gear != 0 )
// {
// transmissionRPM = TransmissionToEngineRPM( Gear );
// transmissionRPM = Gear < 0 ? -transmissionRPM : transmissionRPM;
// rpm = (rpm * clutch) + (MathF.Max( 0, transmissionRPM ) * (1 - clutch));
// }
// var throttle = Throttle;
// var gearTorque = GetTransmissionTorque( Gear, MinRPMTorque, MaxRPMTorque );
// var availableTorque = gearTorque * throttle;
// if ( transmissionRPM < 0 )
// {
// availableTorque += gearTorque * 2;
// }
// else
// {
// var engineBrakeTorque = GetTransmissionTorque( Gear, EngineBrakeTorque, EngineBrakeTorque );
// availableTorque -= engineBrakeTorque * (1 - throttle) * 0.5f;
// }
// var maxRPM = MaxRPM;
// if ( rpm < MinRPM )
// {
// rpm = MinRPM;
// }
// else if ( rpm > maxRPM )
// {
// if ( rpm > maxRPM * 1.2f )
// availableTorque = 0;
// rpm = maxRPM;
// if ( Gear != MaxGear || groundedCount < Wheels.Count )
// isRedlining = true;
// }
// FlywheelRPM = Math.Clamp( rpm, 0, maxRPM );
// if ( burnout > 0 )
// availableTorque += availableTorque * burnout * 0.1f;
// var front = 0.5f + PowerDistribution * 0.5f;
// var rear = 1 - front;
// availableFrontTorque = availableTorque * front;
// availableRearTorque = availableTorque * rear;
// throttle = MathX.Approach( throttle, inputThrottle, dt * 4 );
// EngineAccelerate( FlywheelFriction + FlywheelTorque * throttle, dt );
// Throttle = throttle;
// IsRedlining = (isRedlining && inputThrottle > 0);
//}
}

View File

@ -1,53 +1,51 @@
using Sandbox; using Sandbox;
using System; using System;
using System.Threading; using VeloX.Utils;
namespace VeloX; namespace VeloX;
public partial class VeloXCar public partial class VeloXCar
{ {
public static float ExpDecay( float a, float b, float decay, float dt ) => b + (a - b) * MathF.Exp( -decay * dt ); public static float ExpDecay( float a, float b, float decay, float dt ) => b + (a - b) * MathF.Exp( -decay * dt );
[Property, Feature( "Steer" )] public float SteerConeMaxSpeed { get; set; } = 1800; [Property, Feature( "Steer" )] public float MaxSteerAngle { get; set; } = 40f;
[Property, Feature( "Steer" )] public float SteerConeMaxAngle { get; set; } = 0.25f;
[Property, Feature( "Steer" )] public float SteerConeChangeRate { get; set; } = 8; [ConVar( "steer_return_speed" )]
[Property, Feature( "Steer" )] public float CounterSteer { get; set; } = 0.1f; [Property] public static float SteerReturnSpeed { get; set; } = 6f;
[Property, Feature( "Steer" )] public float MaxSteerAngle { get; set; } = 35f;
[ConVar( "steer_speed" )]
public static float SteerInputResponse { get; set; } = 3f;
[ConVar( "assist_mult" )]
public static float MaxSteerAngleMultiplier { get; set; } = 1f;
public int CarDirection { get { return ForwardSpeed < 1 ? 0 : (VelocityAngle < 90 && VelocityAngle > -90 ? 1 : -1); } }
public float VelocityAngle { get; private set; }
[Sync] public float Steering { get; private set; } [Sync] public float Steering { get; private set; }
private float currentSteerAngle;
private float inputSteer; private float inputSteer;
public static float SignedAngle( Vector3 from, Vector3 to, Vector3 axis )
{
float unsignedAngle = Vector3.GetAngle( from, to );
float cross_x = from.y * to.z - from.z * to.y;
float cross_y = from.z * to.x - from.x * to.z;
float cross_z = from.x * to.y - from.y * to.x;
float sign = MathF.Sign( axis.x * cross_x + axis.y * cross_y + axis.z * cross_z );
return unsignedAngle * sign;
}
public float VelocityAngle { get; private set; }
public int CarDirection { get { return ForwardSpeed.InchToMeter() < 5 ? 0 : (VelocityAngle < 90 && VelocityAngle > -90 ? 1 : -1); } }
private void UpdateSteering( float dt ) private void UpdateSteering( float dt )
{ {
var inputSteer = Input.AnalogMove.y; inputSteer = Input.AnalogMove.y;
VelocityAngle = 0;// -SignedAngle( Body.Velocity, WorldRotation.Forward, WorldRotation.Up ); float targetSteerAngle = inputSteer * MaxSteerAngle;
//var steerConeFactor = Math.Clamp( TotalSpeed / SteerConeMaxSpeed, 0, 1 ); if ( !Input.Down( "Jump" ) )
//var steerCone = 1 - steerConeFactor * (1 - SteerConeMaxAngle); targetSteerAngle *= Math.Clamp( 1 - Math.Clamp( TotalSpeed / 3000, 0.01f, 0.9f ), -1, 1 );
inputSteer = ExpDecay( this.inputSteer, inputSteer, SteerConeChangeRate, dt ); VelocityAngle = -Body.Velocity.SignedAngle( WorldRotation.Forward, WorldRotation.Up );
this.inputSteer = inputSteer;
float target = -inputSteer * MaxSteerAngle; float targetAngle = 0;
if ( CarDirection > 0 )
target -= VelocityAngle * CounterSteer;
inputSteer = Math.Clamp( inputSteer, -1, 1 ); if ( TotalSpeed > 150 && CarDirection > 0 && IsOnGround )
Steering = inputSteer; targetAngle = VelocityAngle * MaxSteerAngleMultiplier;
SteerAngle = new( 0, target, 0 );
float lerpSpeed = Math.Abs( inputSteer ) < 0.1f ? SteerReturnSpeed : SteerInputResponse;
currentSteerAngle = ExpDecay( currentSteerAngle, targetSteerAngle, lerpSpeed, Time.Delta );
Steering = currentSteerAngle + targetAngle;
SteerAngle = new( 0, Math.Clamp( Steering, -MaxSteerAngle, MaxSteerAngle ), 0 );
} }
} }

View File

@ -1,10 +0,0 @@
namespace VeloX;
public partial class VeloXCar
{
private void WheelThink( in float dt )
{
foreach ( var w in Wheels )
w.Update( this, dt );
}
}

View File

@ -7,21 +7,33 @@ namespace VeloX;
[Title( "VeloX - Car" )] [Title( "VeloX - Car" )]
public partial class VeloXCar : VeloXBase public partial class VeloXCar : VeloXBase
{ {
protected override void OnFixedUpdate() protected override void OnFixedUpdate()
{ {
if ( !IsDriver ) if ( IsProxy )
return; return;
base.OnFixedUpdate(); base.OnFixedUpdate();
Brake = Math.Clamp( (Input.Down( "Jump" ) ? 1 : 0), 0, 1 );
var dt = Time.Delta; var dt = Time.Delta;
//EngineThink( dt );
EngineThink( dt ); SimulateAerodinamics( dt );
WheelThink( dt ); //WheelThink( dt );
UpdateSteering( dt ); UpdateSteering( dt );
UpdateUnflip( dt );
UpdateDrift( dt );
} }
private void UpdateUnflip( float dt )
{
if ( Math.Abs( inputSteer ) < 0.1f )
return;
if ( Math.Abs( WorldRotation.Angles().roll ) < 70 )
return;
var angVel = Body.AngularVelocity;
var force = inputSteer * Mass * Math.Clamp( 1 - angVel.x / 50, 0, 1 ) * 0.05f;
Body.AngularVelocity -= Body.WorldRotation.Forward * force * dt;
}
} }

View File

@ -4,7 +4,8 @@ using VeloX.Audio;
namespace VeloX; namespace VeloX;
[GameResource( "Engine Stream", "engstr", "Engine Sound", Category = "VeloX", Icon = "time_to_leave" )] [AssetType( Name = "Engine Stream", Extension = "engstr", Category = "VeloX" )]
[Icon( "time_to_leave" )]
public sealed class EngineStream : GameResource public sealed class EngineStream : GameResource
{ {
public sealed class Layer public sealed class Layer

View File

@ -26,6 +26,7 @@ public class EngineStreamPlayer( EngineStream stream ) : IDisposable
public void Update( float deltaTime, Vector3 position, bool isLocal = false ) public void Update( float deltaTime, Vector3 position, bool isLocal = false )
{ {
var globalPitch = 1.0f; var globalPitch = 1.0f;
// Gear wobble effect // Gear wobble effect
@ -51,13 +52,13 @@ public class EngineStreamPlayer( EngineStream stream ) : IDisposable
{ {
EngineSounds.TryGetValue( layer, out var channel ); EngineSounds.TryGetValue( layer, out var channel );
if ( !channel.IsValid() ) if ( !channel.IsValid() && layer.AudioPath.IsValid() )
{ {
channel = Sound.PlayFile( layer.AudioPath ); channel = Sound.PlayFile( layer.AudioPath );
EngineSounds[layer] = channel; EngineSounds[layer] = channel;
} }
if ( channel.Paused && (EngineSoundPaused || layer.IsMuted) ) if ( !channel.IsValid() || channel.Paused && (EngineSoundPaused || layer.IsMuted) )
continue; continue;
// Reset controller outputs // Reset controller outputs

29
Code/Utils/MathM.cs Normal file
View File

@ -0,0 +1,29 @@
using System;
namespace VeloX.Utils;
public static class MathM
{
/// <summary>
/// Converts angular velocity (rad/s) to rotations per minute.
/// </summary>
public static float AngularVelocityToRPM( this float angularVelocity ) => angularVelocity * 9.5492965855137f;
/// <summary>
/// Converts rotations per minute to angular velocity (rad/s).
/// </summary>
public static float RPMToAngularVelocity( this float RPM ) => RPM * 0.10471975511966f;
public static float SignedAngle( this Vector3 from, Vector3 to, Vector3 axis )
{
float unsignedAngle = Vector3.GetAngle( from, to );
float cross_x = from.y * to.z - from.z * to.y;
float cross_y = from.z * to.x - from.x * to.z;
float cross_z = from.x * to.y - from.y * to.x;
float sign = MathF.Sign( axis.x * cross_x + axis.y * cross_y + axis.z * cross_z );
return unsignedAngle * sign;
}
}

View File

@ -81,6 +81,11 @@ internal sealed class EngineStreamEditor : Widget, IAssetInspector
picker.OnAssetPicked += asset => CreateStreamEditor( asset[0].LoadResource<EngineStream>() ); picker.OnAssetPicked += asset => CreateStreamEditor( asset[0].LoadResource<EngineStream>() );
picker.Show(); picker.Show();
}; };
var saveButton = a.Add( new Button( "Save File" ), 0 );
saveButton.Clicked = () =>
{
ActiveStream.StateHasChanged();
};
} }
Layout RightLayout; Layout RightLayout;
SimulatedEngineWidget SimulatedEngineWidget; SimulatedEngineWidget SimulatedEngineWidget;

View File

@ -76,7 +76,7 @@ internal sealed class SimulatedEngineWidget : Widget
[EditorEvent.Frame] [EditorEvent.Frame]
public void OnFrame() public void OnFrame()
{ {
if ( Player.Stream is null ) if ( Player is null || !Player.Stream.IsValid() )
return; return;
Player.EngineState = IsPlaying ? EngineState.Running : EngineState.Off; Player.EngineState = IsPlaying ? EngineState.Running : EngineState.Off;
@ -85,7 +85,7 @@ internal sealed class SimulatedEngineWidget : Widget
IsRedlining = RPM.Value == 1 && !IsRedlining; IsRedlining = RPM.Value == 1 && !IsRedlining;
Player.IsRedlining = IsRedlining; Player.IsRedlining = IsRedlining;
Player.Update( Time.Delta, Vector3.Zero, true ); Player?.Update( Time.Delta, Vector3.Zero, true );
} }
} }

View File

@ -1,48 +0,0 @@
using Editor;
using Sandbox;
namespace VeloX;
[CustomEditor( typeof( Pacejka ) )]
public class PacejkaWidget : ControlObjectWidget
{
public override bool SupportsMultiEdit => false;
public override bool IncludeLabel => false;
[CustomEditor( typeof( Pacejka.PacejkaPreset ) )]
private class LateralForceWidget : ControlObjectWidget
{
public LateralForceWidget( SerializedProperty property ) : base( property, true )
{
Layout = Layout.Column();
Layout.Margin = 8f;
Layout.Spacing = 8;
foreach ( var item in TypeLibrary.GetType<Pacejka.PacejkaPreset>().Properties )
{
var row = Layout.AddRow();
row.Spacing = 8;
var propetry = SerializedObject.GetProperty( item.Name );
row.Add( new Label( propetry.Name ) );
row.Add( Create( propetry ) );
}
}
}
private Pacejka Pacejka;
public PacejkaWidget( SerializedProperty property ) : base( property, true )
{
var obj = SerializedObject;
Pacejka = obj.ParentProperty.GetValue<Pacejka>();
Layout = Layout.Column();
Layout.Margin = 8f;
Layout.Add( new Label.Body( $" {ToolTip}" ) { Color = Color.White } );
var tabs = Layout.Add( new TabWidget( null ) );
tabs.AddPage( nameof( Pacejka.Lateral ), null,
Layout.Add( Create( obj.GetProperty( nameof( Pacejka.Lateral ) ) ) )
);
tabs.AddPage( nameof( Pacejka.Longitudinal ), null,
Layout.Add( Create( obj.GetProperty( nameof( Pacejka.Longitudinal ) ) ) )
);
}
}

View File

@ -11,17 +11,9 @@ namespace VeloX;
[AssetPreview( "tire" )] [AssetPreview( "tire" )]
class TirePresetPreview : AssetPreview class TirePresetPreview : AssetPreview
{ {
private Texture texture;
public override bool IsAnimatedPreview => false; public override bool IsAnimatedPreview => false;
[Range( 0.01f, 1 )] private float Zoom { get; set; } = 1; [Range( 0.01f, 1 )] private float Zoom { get; set; } = 1;
private TirePreset Tire;
public AssetPreviewWidget Widget { get; private set; }
public override Widget CreateWidget( Widget parent )
{
Widget = parent as AssetPreviewWidget;
return null;
}
public override Widget CreateToolbar() public override Widget CreateToolbar()
{ {
var info = new IconButton( "settings" ); var info = new IconButton( "settings" );
@ -58,55 +50,53 @@ class TirePresetPreview : AssetPreview
using ( Scene.Push() ) using ( Scene.Push() )
{ {
PrimaryObject = new() PrimaryObject = new( true )
{ {
WorldTransform = Transform.Zero WorldTransform = Transform.Zero
}; };
var plane = PrimaryObject.AddComponent<ModelRenderer>(); var spriteRenderer = PrimaryObject.AddComponent<SpriteRenderer>();
plane.Model = Model.Plane;
plane.LocalScale = new Vector3( 1, 1, 1 ); var bitmap = new Bitmap( 512, 512 );
plane.MaterialOverride = Material.Load( "materials/dev/reflectivity_30.vmat" ); var tire = Asset.LoadResource<TirePreset>();
plane.Tint = new Color( 0.02f, 0.04f, 0.03f ); Draw( bitmap, tire );
spriteRenderer.Sprite = new() { Animations = [new() { Frames = [new() { Texture = bitmap.ToTexture() }] },] }; // Set the texture on the renderer
spriteRenderer.Size = 512;
var bounds = PrimaryObject.GetBounds();
SceneCenter = bounds.Center;
SceneSize = bounds.Size;
} }
return; return;
} }
public override void UpdateScene( float cycle, float timeStep ) public override void UpdateScene( float cycle, float timeStep )
{ {
if ( !Widget.IsValid() ) base.UpdateScene( cycle, timeStep );
return;
Camera.WorldPosition = Vector3.Up * 300;
Camera.Orthographic = true; Camera.Orthographic = true;
Camera.WorldRotation = new Angles( 90, 0, 0 ); Camera.OrthographicHeight = 512;
Camera.WorldPosition = Vector3.Backward * 512;
Camera.WorldRotation = Rotation.LookAt( Vector3.Forward );
var bitmap = new Bitmap( 512, 512 ); var bitmap = new Bitmap( 512, 512 );
var tire = Asset.LoadResource<TirePreset>();
Draw( bitmap, tire );
Draw( bitmap ); PrimaryObject.Components.Get<SpriteRenderer>().Sprite = new() { Animations = [new() { Frames = [new() { Texture = bitmap.ToTexture() }] },] }; // Set the texture on the renderer
PrimaryObject.Components.Get<SpriteRenderer>().Size = 512;
texture.Clear( Color.Black );
//texture.Update( bitmap );
DebugOverlaySystem.Current.Texture( texture, new Rect( 0, Widget.Size ) );
FrameScene();
} }
private readonly List<Vector2> pointCache = []; private readonly List<Vector2> pointCache = [];
public TirePresetPreview( Asset asset ) : base( asset ) public TirePresetPreview( Asset asset ) : base( asset )
{ {
texture = Texture.CreateRenderTarget().WithDynamicUsage().WithScreenFormat().WithSize( 512, 512 ).Create();
Tire = Asset.LoadResource<TirePreset>();
} }
private void DrawPacejka( Bitmap bitmap ) private void DrawPacejka( Bitmap bitmap, TirePreset tire )
{ {
var tire = Tire.Pacejka;
var width = bitmap.Width; var width = bitmap.Width;
var height = bitmap.Height; var height = bitmap.Height;
@ -118,32 +108,18 @@ class TirePresetPreview : AssetPreview
for ( float x = 0; x <= 1; x += 0.01f ) for ( float x = 0; x <= 1; x += 0.01f )
{ {
float val = tire.PacejkaFy( x ) * Zoom; float val = tire.Evaluate( x ) * Zoom;
pointCache.Add( new( width * x, height - height * val ) ); pointCache.Add( new( width * x, height - height * val ) );
} }
bitmap.DrawLines( pointCache.ToArray() ); bitmap.DrawLines( pointCache.ToArray() );
} }
{ // draw longitudinal line
pointCache.Clear();
bitmap.SetPen( Color.Green, 1 );
for ( float x = 0; x <= 1; x += 0.01f )
{
float val = tire.PacejkaFx( x ) * Zoom;
pointCache.Add( new( width * x, height - height * val ) );
}
bitmap.DrawLines( pointCache.ToArray() );
}
pointCache.Clear(); pointCache.Clear();
} }
private void Draw( Bitmap bitmap ) private void Draw( Bitmap bitmap, TirePreset tire )
{ {
bitmap.Clear( Color.Black ); bitmap.Clear( Color.Black );
bitmap.SetAntialias( true ); bitmap.SetAntialias( true );
DrawPacejka( bitmap ); DrawPacejka( bitmap, tire );
} }
} }

View File

@ -0,0 +1,27 @@
using Editor;
using Sandbox;
namespace VeloX;
[CustomEditor( typeof( TirePreset ) )]
public class TirePresetWidget : ControlObjectWidget
{
public override bool SupportsMultiEdit => false;
public override bool IncludeLabel => false;
public TirePresetWidget( SerializedProperty property ) : base( property, true )
{
var obj = SerializedObject;
var tirePreset = obj.ParentProperty.GetValue<TirePreset>();
Layout = Layout.Column();
Layout.Margin = 8f;
Layout.Add( new Label.Body( $" {ToolTip}" ) { Color = Color.White } );
Layout.Add( Create( obj.GetProperty( nameof( TirePreset.B ) ) ) );
Layout.Add( Create( obj.GetProperty( nameof( TirePreset.C ) ) ) );
Layout.Add( Create( obj.GetProperty( nameof( TirePreset.D ) ) ) );
Layout.Add( Create( obj.GetProperty( nameof( TirePreset.E ) ) ) );
}
}