diff --git a/Assets/effects/metal_impact.prefab b/Assets/effects/metal_impact.prefab index 02f8ece..860805d 100644 --- a/Assets/effects/metal_impact.prefab +++ b/Assets/effects/metal_impact.prefab @@ -18,7 +18,7 @@ "__type": "Sandbox.ParticleEffect", "__guid": "4e3d505f-af05-4c8f-85ee-c5a264ff23b6", "__enabled": true, - "__version": 1, + "__version": 3, "Alpha": { "Type": "Curve", "Evaluation": "Life", @@ -46,8 +46,7 @@ "out": 0, "mode": "Mirrored" } - ], - "Constants": "1,0,0,0" + ] }, "ApplyAlpha": false, "ApplyColor": true, @@ -63,6 +62,11 @@ "CollisionPrefabChance": 1, "CollisionPrefabRotation": 0, "CollisionRadius": 1, + "ConstantMovement": { + "X": 0, + "Y": 0, + "Z": 0 + }, "Damping": 0, "DieOnCollisionChance": 0, "FollowerPrefab": [], @@ -124,29 +128,17 @@ "ConstantA": "1,1,1,1", "ConstantB": "1,0,0,1" }, + "InitialVelocity": { + "X": 0, + "Y": 0, + "Z": 0 + }, "Lifetime": { "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" - } - ], + "Evaluation": "Seed", "Constants": "0.1,2,0,0" }, + "LocalSpace": 0, "MaxParticles": 500, "OnComponentDestroy": null, "OnComponentDisabled": null, @@ -166,29 +158,7 @@ "Pitch": 0, "PreWarm": 0, "PushStrength": 0, - "Roll": { - "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" - }, + "Roll": 0, "Scale": { "Type": "Curve", "Evaluation": "Life", @@ -230,36 +200,16 @@ "out": 0, "mode": "Mirrored" } - ], - "Constants": "1,0,0,0" + ] }, "SequenceId": 0, "SequenceSpeed": 1, "SequenceTime": 1, "SheetSequence": false, - "Space": "World", "StartDelay": 0, "StartVelocity": { "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" - } - ], + "Evaluation": "Seed", "Constants": "10,70,0,0" }, "Stretch": 0, @@ -267,7 +217,11 @@ "Timing": "GameTime", "Tint": "1,1,1,1", "UsePrefabFeature": false, - "Yaw": 0 + "Yaw": { + "Type": "Range", + "Evaluation": "Seed", + "Constants": "0,360,0,0" + } }, { "__type": "Sandbox.ParticleSpriteRenderer", @@ -302,7 +256,8 @@ "Scale": 1, "Shadows": false, "SortMode": "Unsorted", - "Texture": "materials/particles/shapes/spark2.vtex" + "Texture": "textures/shapes/spark3.vtex", + "TextureFilter": "Bilinear" }, { "__type": "Sandbox.ParticleConeEmitter", diff --git a/Assets/frictions/Michelin.tire b/Assets/frictions/Michelin.tire new file mode 100644 index 0000000..214b05f --- /dev/null +++ b/Assets/frictions/Michelin.tire @@ -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 +} \ No newline at end of file diff --git a/Assets/frictions/default.tire b/Assets/frictions/default.tire index c64e7a2..97ba89f 100644 --- a/Assets/frictions/default.tire +++ b/Assets/frictions/default.tire @@ -1,20 +1,8 @@ { - "Pacejka": { - "Lateral": { - "B": 12, - "C": 1.3, - "D": 1.8, - "E": -1.8 - }, - "Longitudinal": { - "B": 10.86, - "C": 2.15, - "D": 2, - "E": 0.992 - } - }, - "RollResistanceLin": 0.001, - "RollResistanceQuad": 1E-06, + "B": 8.36, + "C": 2.15, + "D": 0.833, + "E": 0.992, "__references": [], "__version": 0 } \ No newline at end of file diff --git a/Assets/prefabs/particles/tire_smoke.prefab b/Assets/prefabs/particles/tire_smoke.prefab new file mode 100644 index 0000000..a4000ed --- /dev/null +++ b/Assets/prefabs/particles/tire_smoke.prefab @@ -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 +} \ No newline at end of file diff --git a/Assets/sounds/tire/skid.wav b/Assets/sounds/tire/skid.wav new file mode 100644 index 0000000..285861b Binary files /dev/null and b/Assets/sounds/tire/skid.wav differ diff --git a/Assets/textures/SmokeTextureSheet.png b/Assets/textures/SmokeTextureSheet.png new file mode 100644 index 0000000..bc6bac2 Binary files /dev/null and b/Assets/textures/SmokeTextureSheet.png differ diff --git a/Assets/textures/smoketexturesheet.vtex b/Assets/textures/smoketexturesheet.vtex new file mode 100644 index 0000000..6196868 --- /dev/null +++ b/Assets/textures/smoketexturesheet.vtex @@ -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" +} \ No newline at end of file diff --git a/Code/Base/Powertrain/Clutch.cs b/Code/Base/Powertrain/Clutch.cs index 600a847..5da8529 100644 --- a/Code/Base/Powertrain/Clutch.cs +++ b/Code/Base/Powertrain/Clutch.cs @@ -1,49 +1,207 @@ -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; - [Property] public float SlipTorque { get; set; } = 1000f; + protected override void OnAwake() + { + base.OnAwake(); + Name ??= "Clutch"; + } - public float Pressing { get; set; } = 1; // todo + /// + /// RPM at which automatic clutch will try to engage. + /// + + [Property] public float EngagementRPM { get; set; } = 1200f; + + public float ThrottleEngagementOffsetRPM = 400f; + + /// + /// 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. + /// + [Range( 0, 1 ), Property] public float ClutchInput { get; set; } + + /// + /// Curve representing pedal travel vs. clutch engagement. Should start at 0,0 and end at 1,1. + /// + [Property] public Curve EngagementCurve { get; set; } = new( new List() { new( 0, 0 ), new( 1, 1 ) } ); + + public enum ClutchControlType + { + Automatic, + Manual + } + + [Property] public ClutchControlType СontrolType { get; set; } = ClutchControlType.Automatic; + + /// + /// 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. + /// + [Property] public float EngagementRange { get; set; } = 400f; + + /// + /// 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. + /// + [Property] public float SlipTorque { get; set; } = 500f; + + + /// + /// 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. + /// + [Range( 0, 100f ), Property] public float CreepTorque { get; set; } = 0; + + [Property] public float CreepSpeedLimit { get; set; } = 1f; + + + /// + /// Clutch engagement based on ClutchInput and the clutchEngagementCurve + /// + [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 = EngagementRPM + 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.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() { - if ( !HasOutput ) + if ( OutputNameHash == 0 ) + { return Inertia; + } - return Inertia + Output.QueryInertia() * Pressing; - } - - public override float QueryAngularVelocity( float angularVelocity ) - { - this.angularVelocity = angularVelocity; - - if ( !HasOutput ) - return angularVelocity; - - - float outputW = Output.QueryAngularVelocity( angularVelocity ) * Pressing; - float inputW = angularVelocity * (1 - Pressing); - - return outputW + inputW; + float I = Inertia + Output.QueryInertia() * _clutchEngagement; + return I; } - 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; - 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; + } + } + + } + } diff --git a/Code/Base/Powertrain/Differential.cs b/Code/Base/Powertrain/Differential.cs new file mode 100644 index 0000000..d37ed74 --- /dev/null +++ b/Code/Base/Powertrain/Differential.cs @@ -0,0 +1,234 @@ +using Sandbox; +using System; + +namespace VeloX; + + +public partial class Differential : PowertrainComponent +{ + + + /// Input torque + /// Angular velocity of the outputA + /// Angular velocity of the outputB + /// Inertia of the outputA + /// Inertia of the outputB + /// Time step + /// Torque bias between outputA and outputB. 0 = all torque goes to A, 1 = all torque goes to B + /// Stiffness of the limited slip or locked differential 0-1 + /// Stiffness under power + /// Stiffness under braking + /// Slip torque of the limited slip differential + /// Torque output towards outputA + /// Torque output towards outputB + 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, + } + + /// + /// Differential type. + /// + [Property] + public DifferentialType Type + { + get => _differentialType; + set + { + _differentialType = value; + AssignDifferentialDelegate(); + } + } + private DifferentialType _differentialType; + + /// + /// Torque bias between left (A) and right (B) output in [0,1] range. + /// + [Property, Range( 0, 1 )] public float BiasAB { get; set; } = 0.5f; + + /// + /// 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. + /// + [Property, Range( 0, 1 ), HideIf( nameof( _differentialType ), DifferentialType.Open )] public float Stiffness { get; set; } = 0.5f; + + /// + /// Stiffness of the LSD differential under acceleration. + /// + [Property, Range( 0, 1 ), ShowIf( nameof( _differentialType ), DifferentialType.LimitedSlip )] public float PowerRamp { get; set; } = 1f; + + /// + /// Stiffness of the LSD differential under braking. + /// + [Property, Range( 0, 1 ), ShowIf( nameof( _differentialType ), DifferentialType.LimitedSlip )] public float CoastRamp { get; set; } = 0.5f; + + + /// + /// Second output of differential. + /// + [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; + + + /// + /// Slip torque of limited slip differentials. + /// + [Property, ShowIf( nameof( _differentialType ), DifferentialType.LimitedSlip )] public float SlipTorque { get; set; } = 400f; + + /// + /// Function delegate that will be used to split the torque between output(A) and outputB. + /// + 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 ); + } + + +} diff --git a/Code/Base/Powertrain/Differential/BaseDifferential.cs b/Code/Base/Powertrain/Differential/BaseDifferential.cs deleted file mode 100644 index 296ce82..0000000 --- a/Code/Base/Powertrain/Differential/BaseDifferential.cs +++ /dev/null @@ -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; - - - /// - /// The PowertrainComponent this component will output to. - /// - [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; - } - -} diff --git a/Code/Base/Powertrain/Differential/OpenDifferential.cs b/Code/Base/Powertrain/Differential/OpenDifferential.cs deleted file mode 100644 index 15a0065..0000000 --- a/Code/Base/Powertrain/Differential/OpenDifferential.cs +++ /dev/null @@ -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; - } -} diff --git a/Code/Base/Powertrain/Engine.cs b/Code/Base/Powertrain/Engine.cs index 400f67e..9163578 100644 --- a/Code/Base/Powertrain/Engine.cs +++ b/Code/Base/Powertrain/Engine.cs @@ -1,109 +1,663 @@ using Sandbox; +using Sandbox.Audio; 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; - [Property, Group( "Settings" )] public float MaxRPM { get; set; } = 7000f; - [Property, Group( "Settings" )] public override float Inertia { get; set; } = 0.151f; - [Property, Group( "Settings" )] public float LimiterDuration { get; set; } = 0.05f; - [Property, Group( "Settings" )] public Curve TorqueMap { get; set; } - [Property, Group( "Settings" )] public EngineStream Stream { get; set; } + protected override void OnAwake() + { + base.OnAwake(); + Name ??= "Engine"; + UpdatePeakPowerAndTorque(); - [Sync] public float Throttle { get; internal set; } + } - [Property] public bool IsRedlining => !limiterTimer; - [Property] public float RPMPercent => Math.Clamp( (RPM - IdleRPM) / (MaxRPM - IdleRPM), 0, 1 ); + [Hide] public new bool Input { get; set; } - private float masterThrottle; - private TimeUntil limiterTimer; - private float finalTorque; + public delegate float CalculateTorque( float angularVelocity, float dt ); + /// + /// Delegate for a function that modifies engine power. + /// + public delegate float PowerModifier(); + + public enum EngineType + { + ICE, + Electric, + } + /// + /// If true starter will be ran for [starterRunTime] seconds if engine receives any throttle input. + /// + [Property] public bool AutoStartOnThrottle { get; set; } = true; + + /// + /// Assign your own delegate to use different type of torque calculation. + /// + public CalculateTorque CalculateTorqueDelegate; + + + /// + /// 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. + /// + [Property] public EngineType Type { get; set; } = EngineType.ICE; + + /// + /// Power generated by the engine in kW + /// + public float generatedPower; + + + /// + /// RPM at which idler circuit will try to keep RPMs when there is no input. + /// + [Property] public float IdleRPM { get; set; } = 900; + + /// + /// Maximum engine power in [kW]. + /// + [Property, Group( "Power" )] public float MaxPower { get; set; } = 120; + + /// + /// Loss power (pumping, friction losses) is calculated as the percentage of maxPower. + /// Should be between 0 and 1 (100%). + /// + [Range( 0, 1 ), Property] public float EngineLossPercent { get; set; } = 0.8f; + + + /// + /// 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. + /// + [Property] public bool FlyingStartEnabled { get; set; } + + [Property] public bool Ignition { get; set; } + + /// + /// 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. + /// + [Property, Group( "Power" )] public Curve PowerCurve { get; set; } = new( new List() { 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 ) } ); + + /// + /// Is the engine currently hitting the rev limiter? + /// + public bool RevLimiterActive; + + /// + /// If engine RPM rises above revLimiterRPM, how long should fuel cutoff last? + /// Higher values make hitting rev limiter more rough and choppy. + /// + [Property] public float RevLimiterCutoffDuration { get; set; } = 0.12f; + + /// + /// Engine RPM at which rev limiter activates. + /// + [Property] public float RevLimiterRPM { get; set; } = 6700; + + /// + /// Is the starter currently active? + /// + [Property, ReadOnly, Group( "Info" )] public bool StarterActive = false; + + /// + /// 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. + /// + [Property] public float StartDuration = 0.5f; + + + /// + /// 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. + /// + [Property, ReadOnly, Group( "Info" )] + public float EstimatedPeakPower => _peakPower; + private float _peakPower; + + + + /// + /// RPM at which the peak power is achieved. + /// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value. + /// + [Property, ReadOnly, Group( "Info" )] + public float EstimatedPeakPowerRPM => _peakPowerRpm; + + private float _peakPowerRpm; + + /// + /// Peak torque value as calculated from the power curve. + /// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value. + /// + [Property, ReadOnly, Group( "Info" )] + public float EstimatedPeakTorque => _peakTorque; + + private float _peakTorque; + + /// + /// 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. + /// + [Property, ReadOnly, Group( "Info" )] + public float EstimatedPeakTorqueRPM => _peakTorqueRpm; + + private float _peakTorqueRpm; + + /// + /// RPM as a percentage of maximum RPM. + /// + [Property, ReadOnly, Group( "Info" )] + public float RPMPercent => _rpmPercent; + + [Sync] private float _rpmPercent { get; set; } + /// + /// Engine throttle position. 0 for no throttle and 1 for full throttle. + /// + [Property, ReadOnly, Group( "Info" )] + public float ThrottlePosition { get; private set; } + + /// + /// Is the engine currently running? + /// Requires ignition to be enabled. + /// + [Property, ReadOnly, Group( "Info" )] + public bool IsRunning { get; private set; } + + /// + /// Is the engine currently running? + /// Requires ignition to be enabled. + /// + [Property, ReadOnly, Group( "Info" )] + public bool IsActive { get; private set; } + + private float _idleAngularVelocity; + + + + /// + /// Current load of the engine, based on the power produced. + /// + 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() { base.OnStart(); - StreamPlayer = new( Stream ); - } - - public float GetFrictionTorque( float throttle, float rpm ) - { - float s = rpm < 0 ? -1f : 1f; - float r = s * rpm * 0.001f; - float f = friction[0] + friction[1] * r + friction[2] * r * r; - return -s * f * (1 - throttle); - } - private float GenerateTorque() - { - float throttle = Throttle; - 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 ) + if ( Type == EngineType.ICE ) { - throttle = 0; - limiterTimer = LimiterDuration; + CalculateTorqueDelegate = CalculateTorqueICE; } - 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 ) + else if ( Type == EngineType.Electric ) { - angularVelocity += GenerateTorque() / Inertia * Time.Delta; - angularVelocity = Math.Max( angularVelocity, 0 ); - return 0; + IdleRPM = 0f; + FlyingStartEnabled = true; + CalculateTorqueDelegate = CalculateTorqueElectric; + StarterActive = false; + StartDuration = 0.001f; + RevLimiterCutoffDuration = 0f; } - float outputInertia = Output.QueryInertia(); - 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() + public void StartEngine() { - if ( StreamPlayer is null ) + if ( IsRunning ) return; + + Ignition = true; + OnEngineStart?.Invoke(); + + if ( Type != EngineType.Electric ) + { + if ( FlyingStartEnabled ) + { + FlyingStart(); + } + else if ( !StarterActive && Controller != null ) + { + StarterCoroutine(); + } + } + } + private async void StarterCoroutine() + { + if ( Type == EngineType.Electric || StarterActive ) return; - StreamPlayer.Throttle = Throttle; - StreamPlayer.RPMPercent = RPMPercent; - StreamPlayer.EngineState = EngineState.Running; - StreamPlayer.IsRedlining = IsRedlining; + try + { + float startTimer = 0f; + 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 GameTask.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(); + } + + + /// + /// Toggles engine state. + /// + 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 GameTask.DelayRealtimeSeconds( RevLimiterCutoffDuration ); + RevLimiterActive = false; + } + + + /// + /// Calculates torque for electric engine type. + /// + 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 ); + } + + + /// + /// Calculates torque for ICE (Internal Combustion Engine). + /// + 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 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; + + } + } } diff --git a/Code/Base/Powertrain/Gearbox/BaseGearbox.cs b/Code/Base/Powertrain/Gearbox/BaseGearbox.cs deleted file mode 100644 index 8a68516..0000000 --- a/Code/Base/Powertrain/Gearbox/BaseGearbox.cs +++ /dev/null @@ -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; - } -} diff --git a/Code/Base/Powertrain/Gearbox/ManualGearbox.cs b/Code/Base/Powertrain/Gearbox/ManualGearbox.cs deleted file mode 100644 index d0c6a65..0000000 --- a/Code/Base/Powertrain/Gearbox/ManualGearbox.cs +++ /dev/null @@ -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 ); - } -} diff --git a/Code/Base/Powertrain/PowerWheel.cs b/Code/Base/Powertrain/PowerWheel.cs deleted file mode 100644 index 437cf08..0000000 --- a/Code/Base/Powertrain/PowerWheel.cs +++ /dev/null @@ -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; - } -} diff --git a/Code/Base/Powertrain/PowertrainComponent.cs b/Code/Base/Powertrain/PowertrainComponent.cs index 1997852..8377b9c 100644 --- a/Code/Base/Powertrain/PowertrainComponent.cs +++ b/Code/Base/Powertrain/PowertrainComponent.cs @@ -1,26 +1,31 @@ -using Sandbox; -using System; +using System; +using Sandbox; -namespace VeloX.Powertrain; +namespace VeloX; public abstract class PowertrainComponent : Component { - protected override void OnAwake() - { - Vehicle ??= Components.Get( FindMode.EverythingInSelfAndAncestors ); - } + [Property] public VeloXBase Controller; + /// + /// Name of the component. Only unique names should be used on the same vehicle. + /// + [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; } - [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. + /// + [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; /// /// Input component. Set automatically. @@ -34,67 +39,143 @@ public abstract class PowertrainComponent : Component if ( value == null || value == this ) { _input = null; + InputNameHash = 0; return; } _input = value; + InputNameHash = _input.GetHashCode(); } } - private PowertrainComponent _input; + protected PowertrainComponent _input; public int InputNameHash; + + /// /// The PowertrainComponent this component will output to. /// [Property] public PowertrainComponent Output { - get => _output; + get { return _output; } set { if ( value == this ) { + Log.Warning( $"{Name}: PowertrainComponent Output can not be self." ); + OutputNameHash = 0; _output = null; return; } _output = value; if ( _output != null ) + { _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; - protected float Torque; + /// + /// Input shaft RPM of component. + /// + [Property, ReadOnly] + public float InputRPM => AngularVelocityToRPM( InputAngularVelocity ); - public virtual bool HasOutput => Output.IsValid(); + + /// + /// Output shaft RPM of component. + /// + [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() { - if ( !HasOutput ) + if ( OutputNameHash == 0 ) 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 ) - return angularVelocity; + InputTorque = torque; + 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 ) - return Torque; + // Power (W) = Torque (Nm) * Angular Velocity (rad/s) + 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; } diff --git a/Code/Base/Powertrain/Transmission.cs b/Code/Base/Powertrain/Transmission.cs new file mode 100644 index 0000000..9f72d25 --- /dev/null +++ b/Code/Base/Powertrain/Transmission.cs @@ -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(); + } + /// + /// A class representing a single ground surface type. + /// + public partial class TransmissionGearingProfile + { + /// + /// List of forward gear ratios starting from 1st forward gear. + /// + public List ForwardGears { get; set; } = [3.59f, 2.02f, 1.38f, 1f, 0.87f]; + + /// + /// List of reverse gear ratios starting from 1st reverse gear. + /// + public List ReverseGears { get; set; } = [-4f,]; + } + + public const float INPUT_DEADZONE = 0.05f; + public float ReferenceShiftRPM => _referenceShiftRPM; + + private float _referenceShiftRPM; + + /// + /// 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. + /// + [Property] public bool HoldToKeepInGear { get; set; } + + /// + /// Final gear multiplier. Each gear gets multiplied by this value. + /// Equivalent to axle/differential ratio in real life. + /// + [Property] public float FinalGearRatio { get; set; } = 4.3f; + /// + /// [Obsolete, will be removed] + /// Currently active gearing profile. + /// Final gear ratio will be determined from this and final gear ratio. + /// + + [Property] public TransmissionGearingProfile GearingProfile { get; set; } = new(); + + + /// + /// 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. + /// + [Property, ReadOnly, Group( "Info" )] public List Gears = new(); + + /// + /// Number of forward gears. + /// + public int ForwardGearCount; + + /// + /// Number of reverse gears. + /// + public int ReverseGearCount; + + /// + /// 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. + /// + [Property, Range( 0, 4 )] public float InclineEffectCoeff { get; set; } + + /// + /// Function that handles gear shifts. + /// Use External transmission type and assign this delegate manually to use a custom + /// gear shift function. + /// + public delegate void Shift( VeloXBase vc ); + + /// + /// Function that changes the gears as required. + /// Use transmissionType External and assign this delegate to use your own gear shift code. + /// + public Shift ShiftDelegate; + + /// + /// Event that gets triggered when transmission shifts down. + /// + public event Action OnGearDownShift; + + /// + /// Event that gets triggered when transmission shifts (up or down). + /// + public event Action OnGearShift; + + /// + /// Event that gets triggered when transmission shifts up. + /// + public event Action OnGearUpShift; + + /// + /// Time after shifting in which shifting can not be done again. + /// + [Property] public float PostShiftBan { get; set; } = 0.5f; + + public enum AutomaticTransmissionDNRShiftType + { + Auto, + RequireShiftInput, + RepeatInput, + } + + /// + /// Behavior when switching from neutral to forward or reverse gear. + /// + [Property] public AutomaticTransmissionDNRShiftType DNRShiftType { get; set; } = AutomaticTransmissionDNRShiftType.Auto; + + /// + /// Speed at which the vehicle can switch between D/N/R gears. + /// + [Property] public float DnrSpeedThreshold { get; set; } = 0.4f; + + /// + /// If set to >0, the clutch will need to be released to the value below the set number + /// for gear shifts to occur. + /// + [Property, Range( 0, 1 )] public float ClutchInputShiftThreshold { get; set; } = 1f; + + /// + /// Time it takes transmission to shift between gears. + /// + [Property] public float ShiftDuration { get; set; } = 0.2f; + + /// + /// Intensity of variable shift point. Higher value will result in shift point moving higher up with higher engine + /// load. + /// + [Property, Range( 0, 1 )] public float VariableShiftIntensity { get; set; } = 0.3f; + + /// + /// If enabled transmission will adjust both shift up and down points to match current load. + /// + [Property] public bool VariableShiftPoint { get; set; } = true; + + /// + /// Current gear ratio. + /// + [Property, ReadOnly] public float CurrentGearRatio { get; set; } + + /// + /// Is the transmission currently in the post-shift phase in which the shifting is disabled/banned to prevent gear hunting? + /// + [Property, ReadOnly] public bool IsPostShiftBanActive { get; set; } + + /// + /// Is a gear shift currently in progress. + /// + [Property, ReadOnly] public bool IsShifting { get; set; } + + /// + /// Progress of the current gear shift in range of 0 to 1. + /// + [Property, ReadOnly] public float ShiftProgress { get; set; } + + + /// + /// Current RPM at which transmission will aim to downshift. All the modifiers are taken into account. + /// This value changes with driving conditions. + /// + [Property] + public float DownshiftRPM + { + get => _downshiftRPM; + set { _downshiftRPM = Math.Clamp( value, 0, float.MaxValue ); } + } + + private float _downshiftRPM = 1400; + /// + /// 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. + /// + [Property] + public float TargetDownshiftRPM => _targetDownshiftRPM; + + private float _targetDownshiftRPM; + /// + /// RPM at which automatic transmission will shift up. If dynamic shift point is enabled this value will change + /// depending on load. + /// + [Property] + public float UpshiftRPM + { + get => _upshiftRPM; + set { _upshiftRPM = Math.Clamp( value, 0, float.MaxValue ); } + } + private float _upshiftRPM = 2800; + + /// + /// 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. + /// + [Property] + public float TargetUpshiftRPM => _targetUpshiftRPM; + private float _targetUpshiftRPM; + + public enum TransmissionShiftType + { + Manual, + Automatic, + //CVT, + } + + /// + /// 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) + /// + [Property] + public TransmissionShiftType TransmissionType + { + get => transmissionType; set + { + if ( value == transmissionType ) + return; + transmissionType = value; + AssignShiftDelegate(); + } + } + /// + /// Is the automatic gearbox sequential? + /// Has no effect on manual transmission. + /// + [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; + + /// + /// 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. + /// + private float _slipOutOfGearTimer = -999f; + + + /// + /// 0 for neutral, less than 0 for reverse gears and lager than 0 for forward gears. + /// Use 'ShiftInto' to set gear. + /// + [Property, Sync] + public int Gear + { + get => IndexToGear( gearIndex ); + set => gearIndex = GearToIndex( value ); + } + + + /// + /// 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. + /// + public int gearIndex; + private TransmissionShiftType transmissionType = TransmissionShiftType.Automatic; + + private int GearToIndex( int g ) + { + return g + ReverseGearCount; + } + + private int IndexToGear( int g ) + { + return g - ReverseGearCount; + } + /// + /// Returns current gear name as a string, e.g. "R", "R2", "N" or "1" + /// + 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 _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(); + } + + /// + /// Total gear ratio of the transmission for current gear. + /// + 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); + } + + + /// + /// Calculates the would-be RPM if none of the wheels was slipping. + /// + /// RPM as it would be if the wheels are not slipping or in the air. + 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 ); + } + } + /// + /// 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. + /// + 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 GameTask.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 GameTask.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 GameTask.DelayRealtimeSeconds( dt ); + } + + // Post shift ban has finished + IsPostShiftBanActive = false; + } + } + private void CVTShift( VeloXBase car ) => AutomaticShift( car ); + + /// + /// Handles automatic and automatic sequential shifting. + /// + 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 ); + } + } + } + } + } + + /// + /// Converts axle RPM to engine RPM for given gear in Gears list. + /// + public float ReverseTransmitRPM( float inputRPM, int g ) + { + float outRpm = inputRPM * Gears[GearToIndex( g )] * FinalGearRatio; + return Math.Abs( outRpm ); + } + +} diff --git a/Code/Base/Powertrain/WheelPowertrain.cs b/Code/Base/Powertrain/WheelPowertrain.cs new file mode 100644 index 0000000..7ab7325 --- /dev/null +++ b/Code/Base/Powertrain/WheelPowertrain.cs @@ -0,0 +1,59 @@ +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; + _initialWheelInertia = Wheel.BaseInertia; + } + private float _initialRollingResistance; + private float _initialWheelInertia; + + 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.Torque = OutputTorque; + Wheel.Inertia = OutputInertia; + + Wheel.AutoSimulate = false; + Wheel.StepPhys( Controller, dt ); + + return Math.Abs( Wheel.CounterTorque ); + } +} diff --git a/Code/Base/VeloXBase.Engine.cs b/Code/Base/VeloXBase.Engine.cs new file mode 100644 index 0000000..899e323 --- /dev/null +++ b/Code/Base/VeloXBase.Engine.cs @@ -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 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.Controller = this; + Engine.Inertia = 0.25f; + Engine.Ignition = false; + + if ( !Clutch.IsValid() ) + Clutch = new GameObject( Engine.GameObject, true, "Clutch" ).GetOrAddComponent(); + + Clutch.Controller = this; + Clutch.Inertia = 0.02f; + + Engine.Output = Clutch; + + if ( !Transmission.IsValid() ) + Transmission = new GameObject( Clutch.GameObject, true, "Transmission" ).GetOrAddComponent(); + + Transmission.Controller = this; + Transmission.Inertia = 0.01f; + + Clutch.Output = Transmission; + + Differential = new TreeBuilder( Transmission, MotorWheels ).Root.Diff; + + Transmission.Output = Differential; + + //PowertrainWheels = Differential.Components.GetAll( 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 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 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() }; + 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(); + 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; + } +} diff --git a/Code/Base/VeloXBase.Input.cs b/Code/Base/VeloXBase.Input.cs index faa966b..ac2507e 100644 --- a/Code/Base/VeloXBase.Input.cs +++ b/Code/Base/VeloXBase.Input.cs @@ -5,19 +5,149 @@ namespace VeloX; public abstract partial class VeloXBase { [Property, Feature( "Input" )] internal InputResolver Input { get; set; } = new(); + [Property, Feature( "Input" )] public Connection Driver { get => Input.Driver; set => Input.Driver = value; } - private Guid _guid; - [Sync( SyncFlags.FromHost )] - public Guid ConnectionID + private bool IsDriverActive => Driver is not null; + + 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 { - _guid = value; - Input.Driver = Connection.Find( _guid ); + float clampedValue = Math.Clamp( value, -1, 1 ); + + if ( value > 0 ) + { + throttle = clampedValue; + brakes = 0; + } + else + { + throttle = 0; + brakes = -clampedValue; + } } } - [Property, Feature( "Input" )] public bool IsDriver => ConnectionID == Connection.Local.Id; + + /// + /// Throttle axis. + /// For combined throttle/brake input use 'VerticalInput' instead. + /// + [Sync( SyncFlags.Interpolate ), Range( 0, 1 ), Property] + public float Throttle + { + get => throttle; + set => throttle = Math.Clamp( value, 0, 1 ); + } + + /// + /// Brake axis. + /// For combined throttle/brake input use 'VerticalInput' instead. + /// + [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(); + } } diff --git a/Code/Base/VeloXBase.Phys.cs b/Code/Base/VeloXBase.Phys.cs index 79d90cf..d3b44bb 100644 --- a/Code/Base/VeloXBase.Phys.cs +++ b/Code/Base/VeloXBase.Phys.cs @@ -1,41 +1,57 @@ using Sandbox; -using System; namespace VeloX; public abstract partial class VeloXBase { + private Vector3 linForce; private Vector3 angForce; private void PhysicsSimulate() { + if ( Body.Sleeping && Input.AnalogMove.x == 0 ) + return; + var drag = AngularDrag; var mass = Body.Mass; var angVel = Body.AngularVelocity; - angForce.x = angVel.x * drag.x * mass; - angForce.y = angVel.y * drag.y * mass; - angForce.z = angVel.z * drag.z * mass; + linForce.x = 0; + linForce.y = 0; + 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 ) - 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.Brake = SwappedBrakes; + if ( !v.IsFront ) + v.Brake += Handbrake; - var force = rt.Dot( vel ) / Time.Delta * mass * factor * rt; - Body.ApplyForce( -force ); + v.Update( this, in dt ); + v.DoPhysics( in dt ); + } + + + + Body.Velocity = vehVel; + Body.AngularVelocity = vehAngVel; } + Body.ApplyForce( linForce ); Body.ApplyTorque( angForce ); + + } } diff --git a/Code/Base/VeloXBase.Wheel.cs b/Code/Base/VeloXBase.Wheel.cs index 79dbfdc..493fe0f 100644 --- a/Code/Base/VeloXBase.Wheel.cs +++ b/Code/Base/VeloXBase.Wheel.cs @@ -1,13 +1,16 @@ using Sandbox; using System.Collections.Generic; +using System.Linq; namespace VeloX; public abstract partial class VeloXBase { + public float CombinedLoad { get; protected set; } [Property] public List Wheels { get; set; } [Property] public TagSet WheelIgnoredTags { get; set; } + public bool IsOnGround => Wheels.Any( x => x.IsOnGround ); public List FindWheels() => [.. Components.GetAll()]; @@ -16,5 +19,4 @@ public abstract partial class VeloXBase { Wheels = FindWheels(); } - } diff --git a/Code/Base/VeloXBase.cs b/Code/Base/VeloXBase.cs index 6215466..8a87bef 100644 --- a/Code/Base/VeloXBase.cs +++ b/Code/Base/VeloXBase.cs @@ -7,8 +7,7 @@ public abstract partial class VeloXBase : Component { [Sync] public WaterState WaterState { get; set; } [Sync] public bool IsEngineOnFire { get; set; } - [Sync, Range( 0, 1 ), Property] public float Brake { get; set; } - [Sync( SyncFlags.Interpolate ), Range( 0, 1 ), Property] public float Throttle { get; set; } + [Property, Sync] public EngineState EngineState { get; set; } [Property] public Vector3 AngularDrag { get; set; } = new( -0.1f, -0.1f, -3 ); [Property] public float Mass { get; set; } = 900; @@ -23,7 +22,7 @@ public abstract partial class VeloXBase : Component protected override void OnFixedUpdate() { - if ( !IsDriver ) + if ( IsProxy ) return; LocalVelocity = WorldTransform.PointToLocal( WorldPosition + Body.Velocity ); @@ -31,7 +30,9 @@ public abstract partial class VeloXBase : Component TotalSpeed = LocalVelocity.Length; Body.PhysicsBody.Mass = Mass; + UpdateInput(); PhysicsSimulate(); + } } diff --git a/Code/Base/Wheel/Pacejka.cs b/Code/Base/Wheel/Pacejka.cs deleted file mode 100644 index 65c8b1d..0000000 --- a/Code/Base/Wheel/Pacejka.cs +++ /dev/null @@ -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 ); -} diff --git a/Code/Base/Wheel/Suspension/BasicSuspension.cs b/Code/Base/Wheel/Suspension/BasicSuspension.cs deleted file mode 100644 index bed19d6..0000000 --- a/Code/Base/Wheel/Suspension/BasicSuspension.cs +++ /dev/null @@ -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 ); - } -} diff --git a/Code/Base/Wheel/Suspension/Hinge.cs b/Code/Base/Wheel/Suspension/Hinge.cs deleted file mode 100644 index db8d1d9..0000000 --- a/Code/Base/Wheel/Suspension/Hinge.cs +++ /dev/null @@ -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 ); - } -} diff --git a/Code/Base/Wheel/Suspension/MacPhersonSuspension.cs b/Code/Base/Wheel/Suspension/MacPhersonSuspension.cs deleted file mode 100644 index 6cd1044..0000000 --- a/Code/Base/Wheel/Suspension/MacPhersonSuspension.cs +++ /dev/null @@ -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 ); - } -} diff --git a/Code/Base/Wheel/TirePreset.cs b/Code/Base/Wheel/TirePreset.cs index 03b9328..11966b5 100644 --- a/Code/Base/Wheel/TirePreset.cs +++ b/Code/Base/Wheel/TirePreset.cs @@ -4,57 +4,45 @@ using System; 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 static TirePreset Default { get; } = ResourceLibrary.Get( "frictions/default.tire" ); - [Property] public Pacejka Pacejka { get; set; } + private float peakSlip = -1; - public float RollResistanceLin { get; set; } = 1E-3f; - public float RollResistanceQuad { get; set; } = 1E-6f; + [Property] public float B { get; set; } = 10.86f; + [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 ) - { // 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 ) + public float GetPeakSlip() { - var abs_lon = Math.Max( MathF.Abs( lon_velocity ), 1e-3f ); - - 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 ); + if ( peakSlip == -1 ) + peakSlip = CalcPeakSlip(); + 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 ); - return ((1 / 6.0f) * (sc * sc) + 1) * sc; + 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 static float ComputeCamberVelocity( float sa, float vx ) - { - float tansa = (1 / 3.0f * (sa * sa) + 1) * sa; - return tansa * vx; - } } diff --git a/Code/Base/Wheel/VeloXWheel.Friction.cs b/Code/Base/Wheel/VeloXWheel.Friction.cs new file mode 100644 index 0000000..21db5e1 --- /dev/null +++ b/Code/Base/Wheel/VeloXWheel.Friction.cs @@ -0,0 +1,299 @@ +using Sandbox; +using System; + +namespace VeloX; + + +public partial class VeloXWheel +{ + /// + /// Constant torque acting similar to brake torque. + /// Imitates rolling resistance. + /// + [Property, Range( 0, 500 ), Sync] public float RollingResistanceTorque { get; set; } = 30f; + + /// + /// The percentage this wheel is contributing to the total vehicle load bearing. + /// + public float LoadContribution { get; set; } = 0.25f; + + /// + /// 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. + /// + [Property, Sync] public float LoadRating { get; set; } = 5400; + + + /// + /// 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. + /// + public float CounterTorque { get; private set; } + + [Property, Sync] public bool AutoSetFriction { get; set; } = true; + [Property, Sync] public bool UseGroundVelocity { get; set; } = true; + + [Property, Range( 0, 2 )] public float BrakeMult { get; set; } = 1f; + [Property] public Friction ForwardFriction = new(); + [Property] 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 = Tire.Evaluate( Math.Abs( ForwardFriction.Slip ) ) * 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 ).RadianToDegree() * 0.01111f; + 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, ContactNormal * 10, overlay: true ); + //DebugOverlay.Sphere( new( ContactPosition, 4 ), overlay: true ); + //Vehicle.Body.ApplyForceAt( ContactPosition, FrictionForce ); + + } + else + FrictionForce = Vector3.Zero; + } +} diff --git a/Code/Base/Wheel/VeloXWheel.Gizmo.cs b/Code/Base/Wheel/VeloXWheel.Gizmo.cs index 79b8425..d73d8a8 100644 --- a/Code/Base/Wheel/VeloXWheel.Gizmo.cs +++ b/Code/Base/Wheel/VeloXWheel.Gizmo.cs @@ -18,7 +18,7 @@ public partial class VeloXWheel : Component // { 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.LineThickness = 0.25f; @@ -28,7 +28,7 @@ public partial class VeloXWheel : Component Gizmo.Draw.Line( suspensionStart + Vector3.Forward, suspensionStart + 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 // @@ -36,7 +36,7 @@ public partial class VeloXWheel : Component Gizmo.Draw.LineThickness = 0.5f; Gizmo.Draw.Color = Color.White; - Gizmo.Draw.LineCylinder( widthOffset, -widthOffset, Radius, Radius, 16 ); + Gizmo.Draw.LineCylinder( widthOffset, -widthOffset, Radius.MeterToInch(), Radius.MeterToInch(), 16 ); } // @@ -51,11 +51,11 @@ public partial class VeloXWheel : Component 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 ) ); - 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 ); } } @@ -64,7 +64,7 @@ public partial class VeloXWheel : Component //// Forward direction //// //{ - // var arrowStart = Vector3.Forward * Radius; + // var arrowStart = Vector3.Forward * Radius.MeterToInch(); // var arrowEnd = arrowStart + Vector3.Forward * 8f; // Gizmo.Draw.Color = Color.Red; diff --git a/Code/Base/Wheel/VeloXWheel.Skid.cs b/Code/Base/Wheel/VeloXWheel.Skid.cs new file mode 100644 index 0000000..2f8ca27 --- /dev/null +++ b/Code/Base/Wheel/VeloXWheel.Skid.cs @@ -0,0 +1,89 @@ +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 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(); + _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 ); + 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 ); + _skidMark.Points.Add( go ); + } +} diff --git a/Code/Base/Wheel/VeloXWheel.Smoke.cs b/Code/Base/Wheel/VeloXWheel.Smoke.cs new file mode 100644 index 0000000..3c214b6 --- /dev/null +++ b/Code/Base/Wheel/VeloXWheel.Smoke.cs @@ -0,0 +1,109 @@ +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 0; + } + 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( 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( 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() { new( 0, 10f ), new( 0.8f, 50f ), new( 1f, 160f * sizeMul ) } ), + }; + 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 = hitForwardDirection * LongitudinalSlip * 10f; + effect.ForceDirection = 0; + effect.SheetSequence = true; + effect.SequenceSpeed = 0.5f; + effect.SequenceTime = 1f; + + + } +} diff --git a/Code/Base/Wheel/VeloXWheel.cs b/Code/Base/Wheel/VeloXWheel.cs index 4908d34..6624584 100644 --- a/Code/Base/Wheel/VeloXWheel.cs +++ b/Code/Base/Wheel/VeloXWheel.cs @@ -1,16 +1,5 @@ using Sandbox; -using Sandbox.Rendering; -using Sandbox.Services; -using Sandbox.UI; 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; @@ -18,72 +7,67 @@ namespace VeloX; [Title( "VeloX - Wheel" )] 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] public float Mass { get; set; } = 20; - [Property] public float RollingResistance { get; set; } = 20; - [Property] public float SlipCircleShape { get; set; } = 1.05f; - [Property] public TirePreset TirePreset { get; set; } - [Property] public float Width { get; set; } = 6; - public float SideSlip { get; private set; } - public float ForwardSlip { get; private set; } + [Property, Group( "Traction" )] public TirePreset Tire { get; set; } = ResourceLibrary.Get( "frictions/default.tire" ); + [Property, Group( "Traction" )] public float SurfaceGrip { get; set; } = 1f; + [Property, Group( "Traction" )] public float SurfaceResistance { get; set; } = 0.05f; + public bool AutoSimulate = true; + + + public float BaseInertia => Mass * (Radius * Radius); // kg·m² + [Property] public float Inertia { get; set; } = 1.5f; // kg·m² + public float SideSlip => SlipAngle; + public float ForwardSlip => DynamicSlipRatio; [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 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; - [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; } + public float RPM { get => AngularVelocity * 60f / MathF.Tau; set => AngularVelocity = value / (60 / MathF.Tau); } + [Sync] internal float DistributionFactor { 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; } - public bool IsOnGround => Trace.Hit; + [Sync] public bool IsOnGround { get; private set; } - private float lastSpringOffset; - private float angularVelocity; - private float load; - private float lastFraction; - private Vector3 contactPos; - private Vector3 forward; - private Vector3 right; - private Vector3 up; + [Property] public float DriveTorque => Torque; + [Property] public float BrakeTorque => Brake * 5000f; - private float forwardFriction; - private float sideFriction; - private Vector3 force; - public float CounterTorque { get; private set; } + public float Compression { get; protected set; } // meters + public float LastLength { get; protected set; } // meters + public float Fz { get; protected set; } // N + public float AngularVelocity { get; protected set; } // rad/s + public float Fx { get; protected set; } // N + public float Fy { get; protected set; } // N + 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; } + public float SlipRatio { get; protected set; } + public float SlipAngle { get; protected set; } + public float DynamicSlipRatio { get; protected set; } + public float DynamicSlipAngle { get; protected set; } + Rotation TransformRotationSteer => Vehicle.WorldTransform.RotationToWorld( Vehicle.SteerAngle * SteerMultiplier ); protected override void OnAwake() { + Vehicle = Components.Get( FindMode.EverythingInSelfAndAncestors ); base.OnAwake(); if ( StartPos.IsNearZeroLength ) StartPos = LocalPosition; + Inertia = BaseInertia; } internal void Update( VeloXBase vehicle, in float dt ) @@ -93,224 +77,198 @@ public partial class VeloXWheel : Component private void UpdateVisuals( VeloXBase vehicle, in float dt ) { - Spin -= angularVelocity.RadianToDegree() * dt; - WorldRotation = vehicle.WorldTransform.RotationToWorld( GetSteer( vehicle.SteerAngle.yaw ) ) * Rotation.FromAxis( Vector3.Right, Spin ); + WorldRotation = vehicle.WorldTransform.RotationToWorld( vehicle.SteerAngle * SteerMultiplier ).RotateAroundAxis( Vector3.Right, -RollAngle ); + LocalPosition = StartPos + Vector3.Down * LastLength.MeterToInch(); } - private Rotation GetSteer( float steer ) + private struct WheelTraceData { - - float angle = (-steer * SteerMultiplier).DegreeToRadian(); - - float t = MathF.Tan( (MathF.PI / 2) - angle ) - Ackermann; - float steering_angle = MathF.CopySign( float.Pi / 2, t ) - MathF.Atan( t ); - var steering_axis = Vector3.Up * MathF.Cos( -CasterAngle.DegreeToRadian() ) + - Vector3.Right * MathF.Sin( -CasterAngle.DegreeToRadian() ); - - return Rotation.FromAxis( Vector3.Forward, -CamberAngle ) * Rotation.FromAxis( steering_axis, steering_angle.RadianToDegree() ); + internal Vector3 ContactNormal; + internal Vector3 ContactPosition; + internal float Compression; + internal float Force; + } + public void UpdateForce() + { + Vehicle.Body.ApplyForceAt( ContactPosition, FrictionForce + ContactNormal * Fz.MeterToInch() ); } - - private static float GetLongitudinalLoadCoefficient( float load ) => 11000 * (1 - MathF.Exp( -0.00014f * load )); - private static float GetLateralLoadCoefficient( float load ) => 18000 * (1 - MathF.Exp( -0.0001f * load )); - private float inertia; - - - private (float, float, float, float) StepLongitudinal( float Tm, float Tb, float Vx, float W, float Lc, float R, float I ) + internal void StepPhys( VeloXBase vehicle, in float dt ) { - float wInit = W; - float vxAbs = Math.Abs( Vx ); - float Sx; - if ( Lc < 0.01f ) + + var _rigidbody = vehicle.Body; + const int numSamples = 3; + float halfWidth = Width.MeterToInch() * 0.5f; + + var ang = vehicle.WorldTransform.RotationToWorld( vehicle.SteerAngle * SteerMultiplier ); + + Vector3 right = Vector3.VectorPlaneProject( ang.Right, Vector3.Up ).Normal; + + + int hitCount = 0; + WheelTraceData wheelTraceData = new(); + for ( int i = 0; i < numSamples; i++ ) { - Sx = 0; + float t = (float)i / (numSamples - 1); + 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++; } - else if ( vxAbs >= 0.01f ) + + + if ( hitCount > 0 ) { - Sx = (W * R - Vx) / vxAbs; + + IsOnGround = true; + + //// Average all contacts + Fz = Math.Max( wheelTraceData.Force / hitCount, 0 ); + Compression = wheelTraceData.Compression / hitCount; + ContactNormal = (wheelTraceData.ContactNormal / hitCount).Normal; + ContactPosition = wheelTraceData.ContactPosition / hitCount; + //DoSuspensionSounds( vehicle, (RestLength - Compression) * 0.8f); + LastLength = RestLength - Compression; + + //DebugOverlay.Normal( ContactPosition, ContactNormal * Fz / 1000 ); + // Apply suspension force + //_rigidbody.ApplyForceAt( ContactPosition, ContactNormal * Fz.MeterToInch() ); + + UpdateHitVariables(); + // Friction + + Vector3 forward = ContactNormal.Cross( right ).Normal; + right = Rotation.FromAxis( ContactNormal, 90f ) * forward; + + var velAtContact = _rigidbody.GetVelocityAtPoint( ContactPosition + vehicle.Body.MassCenter ) * 0.0254f; + float vx = Vector3.Dot( velAtContact, forward ); + float vy = Vector3.Dot( velAtContact, right ); + float wheelLinearVel = AngularVelocity * Radius; + + float creepVel = 0.5f; + SlipRatio = (wheelLinearVel - vx) / (MathF.Abs( vx ) + creepVel); + SlipAngle = MathX.RadianToDegree( MathF.Atan2( vy, MathF.Abs( vx ) + creepVel ) ); + + float latCoeff = 1.0f - MathF.Exp( -MathF.Max( MathF.Abs( vx ), 1f ) * dt / 0.01f ); + float longCoeff = 1.0f - MathF.Exp( -MathF.Max( MathF.Abs( vx ), 1f ) * dt / 0.01f ); + + DynamicSlipAngle += (SlipAngle - DynamicSlipAngle) * latCoeff; + DynamicSlipRatio += (SlipRatio - DynamicSlipRatio) * longCoeff; + + + UpdateFriction( dt ); } else { - Sx = (W * R - Vx) * 0.6f; + IsOnGround = false; + // Wheel is off the ground + Compression = 0f; + Fz = 0f; + Fx = 0f; + Fy = 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"; + + private bool TraceWheel( VeloXBase vehicle, ref WheelTraceData wheelTraceData, Vector3 start, Vector3 end, float width, in float dt ) { - float VxAbs = Math.Abs( Vx ); - if ( Lc < 0.01f ) - { - Sy = 0; - } - else if ( VxAbs > 0.1f ) + var trace = Scene.Trace + .FromTo( start, end ) + .Cylinder( width, Radius.MeterToInch() ) + .Rotated( vehicle.WorldRotation * CylinderOffset ) + .UseHitPosition( false ) + .IgnoreGameObjectHierarchy( Vehicle.GameObject ) + .WithoutTags( playerTag ) + .Run(); + + //DebugOverlay.Trace( trace, overlay: true ); + if ( trace.Hit ) { - Sy = MathX.RadianToDegree( MathF.Atan( Vy / VxAbs ) ) / 50; - } - else - { + Vector3 contactPos = trace.EndPosition; + Vector3 contactNormal = trace.Normal; + float currentLength = trace.Distance.InchToMeter(); + float compression = (RestLength - currentLength).Clamp( -RestLength, RestLength ); - Sy = Vy * (0.003f / Time.Delta); + // Nonlinear spring + float springForce = SpringStiffness * compression * (1f + 2f * compression / RestLength); + + // Damping + float damperVelocity = (LastLength - currentLength) / dt; + float damperForce = damperVelocity > 0 ? damperVelocity * ReboundStiffness : damperVelocity * CompressionStiffness; + + float FzPoint = springForce + damperForce; + + // 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; } - Sy = Math.Clamp( Sy, -1, 1 ); - float slipSign = Sy < 0 ? -1 : 1; - Fy = -slipSign * TirePreset.Pacejka.PacejkaFy( Math.Abs( Sy ) ) * Lc; + return false; } - private void SlipCircle( float Sx, float Sy, float Fx, ref float Fy ) + + public void DoPhysics( in float dt ) { - 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(); - - forward = Vector3.VectorPlaneProject( ang.Forward, Trace.Normal ); - right = Vector3.VectorPlaneProject( ang.Right, Trace.Normal ); - - var fraction = Trace.Fraction; - - contactPos = pos - maxLen * fraction * ang.Up; - - LocalPosition = vehicle.WorldTransform.PointToLocal( contactPos ); - - DoSuspensionSounds( vehicle, fraction - lastFraction ); - lastFraction = fraction; - - if ( !IsOnGround ) + if ( IsProxy ) return; - - var vel = vehicle.Body.GetVelocityAtPoint( contactPos ); - - var offset = maxLen - (fraction * maxLen); - 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 ); - - vehicle.Body.Velocity += linearImp; - 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; - damperForce = 0; - - } - - 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 ); - + StepRotation( Vehicle, dt ); + if ( AutoSimulate ) + StepPhys( Vehicle, dt ); } -#if DEBUG - protected override void OnUpdate() + private void StepRotation( VeloXBase vehicle, in float dt ) { - DebugOverlay.Normal( contactPos, forward * forwardFriction, Color.Red, overlay: true ); - DebugOverlay.Normal( contactPos, right * sideFriction, Color.Green, overlay: true ); - DebugOverlay.Normal( contactPos, up * force / 1000f, Color.Blue, overlay: true ); - } -#endif + float inertia = MathF.Max( 1f, Inertia ); + float roadTorque = Fx * Radius; + float externalTorque = DriveTorque - roadTorque; + float rollingResistanceTorque = Fz * Radius * SurfaceResistance; + + const float HubCoulombNm = 20f; + const float HubViscous = 0.1f; + + float coulombTorque = BrakeTorque + rollingResistanceTorque + HubCoulombNm; + + float omega = AngularVelocity; + + if ( MathF.Abs( omega ) < 1e-6f && MathF.Abs( externalTorque ) <= coulombTorque ) + { + AngularVelocity = 0f; + } + else + { + + // viscous decay + if ( HubViscous > 0f ) omega *= MathF.Exp( -(HubViscous / inertia) * dt ); + + // Coulomb drag + 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; // wider sanity range + + RollAngle += MathX.RadianToDegree( AngularVelocity ) * dt; + RollAngle = (RollAngle % 360f + 360f) % 360f; + } } diff --git a/Code/Base/Wheel/WheelManager.cs b/Code/Base/Wheel/WheelManager.cs new file mode 100644 index 0000000..a8d5d6f --- /dev/null +++ b/Code/Base/Wheel/WheelManager.cs @@ -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(); + 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(); + 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 ); + } +} diff --git a/Code/Car/VeloXCar.Aerodinamics.cs b/Code/Car/VeloXCar.Aerodinamics.cs new file mode 100644 index 0000000..3b50183 --- /dev/null +++ b/Code/Car/VeloXCar.Aerodinamics.cs @@ -0,0 +1,55 @@ +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 = 10.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 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 ); + } +} diff --git a/Code/Car/VeloXCar.Drift.cs b/Code/Car/VeloXCar.Drift.cs new file mode 100644 index 0000000..16b3c27 --- /dev/null +++ b/Code/Car/VeloXCar.Drift.cs @@ -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; + + + } +} diff --git a/Code/Car/VeloXCar.Engine.cs b/Code/Car/VeloXCar.Engine.cs deleted file mode 100644 index b6748ae..0000000 --- a/Code/Car/VeloXCar.Engine.cs +++ /dev/null @@ -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 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); - //} - -} diff --git a/Code/Car/VeloXCar.Steering.cs b/Code/Car/VeloXCar.Steering.cs index 55399bb..5671228 100644 --- a/Code/Car/VeloXCar.Steering.cs +++ b/Code/Car/VeloXCar.Steering.cs @@ -1,53 +1,51 @@ using Sandbox; using System; -using System.Threading; +using VeloX.Utils; namespace VeloX; public partial class VeloXCar { 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 SteerConeMaxAngle { get; set; } = 0.25f; - [Property, Feature( "Steer" )] public float SteerConeChangeRate { get; set; } = 8; - [Property, Feature( "Steer" )] public float CounterSteer { get; set; } = 0.1f; - [Property, Feature( "Steer" )] public float MaxSteerAngle { get; set; } = 35f; + [Property, Feature( "Steer" )] public float MaxSteerAngle { get; set; } = 40f; + + [ConVar( "steer_return_speed" )] + [Property] public static float SteerReturnSpeed { get; set; } = 6f; + + [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; } + private float currentSteerAngle; 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 ) { - 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 ); - //var steerCone = 1 - steerConeFactor * (1 - SteerConeMaxAngle); + if ( !Input.Down( "Jump" ) ) + targetSteerAngle *= Math.Clamp( 1 - Math.Clamp( TotalSpeed / 3000, 0.01f, 0.9f ), -1, 1 ); - inputSteer = ExpDecay( this.inputSteer, inputSteer, SteerConeChangeRate, dt ); - this.inputSteer = inputSteer; + VelocityAngle = -Body.Velocity.SignedAngle( WorldRotation.Forward, WorldRotation.Up ); - float target = -inputSteer * MaxSteerAngle; - if ( CarDirection > 0 ) - target -= VelocityAngle * CounterSteer; + float targetAngle = 0; - inputSteer = Math.Clamp( inputSteer, -1, 1 ); - Steering = inputSteer; - SteerAngle = new( 0, target, 0 ); + if ( TotalSpeed > 150 && CarDirection > 0 && IsOnGround ) + targetAngle = VelocityAngle * MaxSteerAngleMultiplier; + + 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 ); } } diff --git a/Code/Car/VeloXCar.Wheel.cs b/Code/Car/VeloXCar.Wheel.cs deleted file mode 100644 index dbd9ad1..0000000 --- a/Code/Car/VeloXCar.Wheel.cs +++ /dev/null @@ -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 ); - } -} diff --git a/Code/Car/VeloXCar.cs b/Code/Car/VeloXCar.cs index e4d7fdf..d2f7dfc 100644 --- a/Code/Car/VeloXCar.cs +++ b/Code/Car/VeloXCar.cs @@ -7,21 +7,33 @@ namespace VeloX; [Title( "VeloX - Car" )] public partial class VeloXCar : VeloXBase { + protected override void OnFixedUpdate() { - if ( !IsDriver ) + if ( IsProxy ) return; base.OnFixedUpdate(); - Brake = Math.Clamp( (Input.Down( "Jump" ) ? 1 : 0), 0, 1 ); - var dt = Time.Delta; - - EngineThink( dt ); - WheelThink( dt ); + //EngineThink( dt ); + SimulateAerodinamics( dt ); + //WheelThink( 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; + } } diff --git a/Code/Utils/EngineStream/EngineStream.cs b/Code/Utils/EngineStream/EngineStream.cs index f5905e3..8be3cfd 100644 --- a/Code/Utils/EngineStream/EngineStream.cs +++ b/Code/Utils/EngineStream/EngineStream.cs @@ -4,7 +4,8 @@ using VeloX.Audio; 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 Layer diff --git a/Code/Utils/EngineStream/EngineStreamPlayer.cs b/Code/Utils/EngineStream/EngineStreamPlayer.cs index 3e9a09c..c18f602 100644 --- a/Code/Utils/EngineStream/EngineStreamPlayer.cs +++ b/Code/Utils/EngineStream/EngineStreamPlayer.cs @@ -26,6 +26,7 @@ public class EngineStreamPlayer( EngineStream stream ) : IDisposable public void Update( float deltaTime, Vector3 position, bool isLocal = false ) { + var globalPitch = 1.0f; // Gear wobble effect @@ -50,14 +51,14 @@ public class EngineStreamPlayer( EngineStream stream ) : IDisposable foreach ( var (id, layer) in Stream.Layers ) { EngineSounds.TryGetValue( layer, out var channel ); - - if ( !channel.IsValid() ) + + if ( !channel.IsValid() && layer.AudioPath.IsValid() ) { channel = Sound.PlayFile( layer.AudioPath ); EngineSounds[layer] = channel; } - if ( channel.Paused && (EngineSoundPaused || layer.IsMuted) ) + if ( !channel.IsValid() || channel.Paused && (EngineSoundPaused || layer.IsMuted) ) continue; // Reset controller outputs diff --git a/Code/Utils/MathM.cs b/Code/Utils/MathM.cs new file mode 100644 index 0000000..df7a4b5 --- /dev/null +++ b/Code/Utils/MathM.cs @@ -0,0 +1,29 @@ +using System; + +namespace VeloX.Utils; + +public static class MathM +{ + + /// + /// Converts angular velocity (rad/s) to rotations per minute. + /// + public static float AngularVelocityToRPM( this float angularVelocity ) => angularVelocity * 9.5492965855137f; + + /// + /// Converts rotations per minute to angular velocity (rad/s). + /// + 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; + } + +} diff --git a/Editor/ESEditor/EngineStreamEditor.cs b/Editor/ESEditor/EngineStreamEditor.cs index 8511ec7..193dca7 100644 --- a/Editor/ESEditor/EngineStreamEditor.cs +++ b/Editor/ESEditor/EngineStreamEditor.cs @@ -81,6 +81,11 @@ internal sealed class EngineStreamEditor : Widget, IAssetInspector picker.OnAssetPicked += asset => CreateStreamEditor( asset[0].LoadResource() ); picker.Show(); }; + var saveButton = a.Add( new Button( "Save File" ), 0 ); + saveButton.Clicked = () => + { + ActiveStream.StateHasChanged(); + }; } Layout RightLayout; SimulatedEngineWidget SimulatedEngineWidget; diff --git a/Editor/ESEditor/SimulatedEngineWidget.cs b/Editor/ESEditor/SimulatedEngineWidget.cs index 003a5b3..17d77a0 100644 --- a/Editor/ESEditor/SimulatedEngineWidget.cs +++ b/Editor/ESEditor/SimulatedEngineWidget.cs @@ -76,7 +76,7 @@ internal sealed class SimulatedEngineWidget : Widget [EditorEvent.Frame] public void OnFrame() { - if ( Player.Stream is null ) + if ( Player is null || !Player.Stream.IsValid() ) return; Player.EngineState = IsPlaying ? EngineState.Running : EngineState.Off; @@ -85,7 +85,7 @@ internal sealed class SimulatedEngineWidget : Widget IsRedlining = RPM.Value == 1 && !IsRedlining; Player.IsRedlining = IsRedlining; - Player.Update( Time.Delta, Vector3.Zero, true ); + Player?.Update( Time.Delta, Vector3.Zero, true ); } } diff --git a/Editor/Wheel/PacejkaWidget.cs b/Editor/Wheel/PacejkaWidget.cs deleted file mode 100644 index 8efcaa8..0000000 --- a/Editor/Wheel/PacejkaWidget.cs +++ /dev/null @@ -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().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(); - - 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 ) ) ) ) - ); - } -} diff --git a/Editor/Wheel/TirePresetPreview.cs b/Editor/Wheel/TirePresetPreview.cs index 7575ddf..8b170d1 100644 --- a/Editor/Wheel/TirePresetPreview.cs +++ b/Editor/Wheel/TirePresetPreview.cs @@ -11,17 +11,9 @@ namespace VeloX; [AssetPreview( "tire" )] class TirePresetPreview : AssetPreview { - private Texture texture; public override bool IsAnimatedPreview => false; [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() { var info = new IconButton( "settings" ); @@ -58,55 +50,53 @@ class TirePresetPreview : AssetPreview using ( Scene.Push() ) { - PrimaryObject = new() + PrimaryObject = new( true ) { WorldTransform = Transform.Zero }; - var plane = PrimaryObject.AddComponent(); - plane.Model = Model.Plane; - plane.LocalScale = new Vector3( 1, 1, 1 ); - plane.MaterialOverride = Material.Load( "materials/dev/reflectivity_30.vmat" ); - plane.Tint = new Color( 0.02f, 0.04f, 0.03f ); + var spriteRenderer = PrimaryObject.AddComponent(); + + var bitmap = new Bitmap( 512, 512 ); + var tire = Asset.LoadResource(); + 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; } public override void UpdateScene( float cycle, float timeStep ) { - if ( !Widget.IsValid() ) - return; + base.UpdateScene( cycle, timeStep ); - Camera.WorldPosition = Vector3.Up * 300; 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 tire = Asset.LoadResource(); + Draw( bitmap, tire ); - Draw( bitmap ); + PrimaryObject.Components.Get().Sprite = new() { Animations = [new() { Frames = [new() { Texture = bitmap.ToTexture() }] },] }; // Set the texture on the renderer + PrimaryObject.Components.Get().Size = 512; - texture.Clear( Color.Black ); - //texture.Update( bitmap ); - DebugOverlaySystem.Current.Texture( texture, new Rect( 0, Widget.Size ) ); - FrameScene(); } private readonly List pointCache = []; public TirePresetPreview( Asset asset ) : base( asset ) { - texture = Texture.CreateRenderTarget().WithDynamicUsage().WithScreenFormat().WithSize( 512, 512 ).Create(); - Tire = Asset.LoadResource(); } - private void DrawPacejka( Bitmap bitmap ) + private void DrawPacejka( Bitmap bitmap, TirePreset tire ) { - var tire = Tire.Pacejka; + var width = bitmap.Width; var height = bitmap.Height; @@ -118,32 +108,18 @@ class TirePresetPreview : AssetPreview 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 ) ); } 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(); } - private void Draw( Bitmap bitmap ) + private void Draw( Bitmap bitmap, TirePreset tire ) { bitmap.Clear( Color.Black ); bitmap.SetAntialias( true ); - DrawPacejka( bitmap ); + DrawPacejka( bitmap, tire ); } } diff --git a/Editor/Wheel/TirePresetWidget.cs b/Editor/Wheel/TirePresetWidget.cs new file mode 100644 index 0000000..c7de0d3 --- /dev/null +++ b/Editor/Wheel/TirePresetWidget.cs @@ -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(); + + 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 ) ) ) ); + + } +}