I donât think jailbreak has solved it.
In before roblox officialy creates their own physics substepping option but this will do for now.
Havenât fully stressed test it releasing for free for feedback.
The key I found is using 5 physics substeps and not 2⌠the suspensions are not that stable. Idea stolen from unity asset
Also the calculations include Robloxâs built in angular velocity dampening which I trialed through plotting a data.
Seems like the equation for the built in angular velocity dampening is e^ -0.05 * t where t is time, 1000 is the initial angular velocity.
Long code module and normal script
local module = {}
local function predictVelocity(acceleration, currentYVelocity, timeStep : number)
local addedVelocity = acceleration*timeStep --Integrate acceleration, assuming constant acceleration
return currentYVelocity + addedVelocity
end
local function predictDisplacement(acceleration, currentVelocity, timeStep)
return currentVelocity*timeStep
end
function getVelocityAtPoint(velocity, rotationalVelocity, centerOfMass, worldPoint)
return velocity + rotationalVelocity:Cross(worldPoint - centerOfMass)
end
function module.calculateNetSpringForce(chassisData, SpringVectorForceDictionary, params, MaxSpringLength, calculatedStiffnessValue, dampingCoefficient, raycast_visualization_dictionary, overlapParams, substepCalculation)
local simulatedChassisCFrame = chassisData.CurrentCFrame --from primary part
local centerOfMassOffset : Vector3 = chassisData.CenterOfMassOffset
local assemblyAngularVelocity = chassisData.AssemblyAngularVelocity
local assemblyVelocity = chassisData.AssemblyVelocity
--if raycast_visualization_dictionary then
-- local chassisDebug = raycast_visualization_dictionary["ChassisSimulation"]
-- chassisDebug.CFrame = simulatedChassisCFrame
--end
local netTranslationForce = Vector3.zero
local netTorque = Vector3.zero
local centerOfMass = simulatedChassisCFrame*centerOfMassOffset
for wheel_name, vectorforce : VectorForce in pairs(SpringVectorForceDictionary) do
local attachment = vectorforce.Attachment0
local wheelAttachmentCFrame = simulatedChassisCFrame*attachment.CFrame
local wheelAttachmentPos = (wheelAttachmentCFrame).Position
local rayDirection = -simulatedChassisCFrame.UpVector*MaxSpringLength
local raycastResult = workspace:Raycast(wheelAttachmentPos, rayDirection, params)
--print((wheelAttachmentPos- centerOfMass).Magnitude)
local velocity = getVelocityAtPoint(assemblyVelocity, assemblyAngularVelocity, centerOfMass, wheelAttachmentPos)
velocity = -rayDirection.Unit:Dot(velocity)
local raycast_debug_part
if raycast_visualization_dictionary then
raycast_debug_part = raycast_visualization_dictionary[wheel_name]
raycast_debug_part.CFrame = CFrame.lookAt(wheelAttachmentPos, wheelAttachmentPos+rayDirection)
end
--print(#collisionCheck)
if raycastResult then
local raycast_distance = (wheelAttachmentPos - raycastResult.Position).Magnitude
local spring_length = math.clamp(raycast_distance, 0, MaxSpringLength)
local spring_displacement = MaxSpringLength - spring_length
local springDirection = simulatedChassisCFrame.UpVector
local springForceMagnitude = spring_displacement*calculatedStiffnessValue
local dampening = velocity*dampingCoefficient --Problem damping not good 60 fps, need roblox 240 Hz or physics substepping, solution use bodyvelocity
local radius = (wheelAttachmentPos - centerOfMass)
local springForceVector = (springForceMagnitude - dampening)*springDirection
netTorque += radius:Cross(springForceVector)
netTranslationForce += springForceVector
--if wheel_name == "back_left" then
-- print("hit: ", wheel_name, spring_displacement)
--end
if raycast_debug_part then
raycast_debug_part.BrickColor = BrickColor.Green()
--raycast_debug_part.Size = Vector3.new(0.1, 0.1, raycast_distance)
raycast_debug_part.CFrame *= CFrame.new(0, 0, -raycast_distance/2)
end
else
if raycast_debug_part then
raycast_debug_part.BrickColor = BrickColor.Red()
raycast_debug_part.Size = Vector3.new(0.1, 0.1, rayDirection.Magnitude)
raycast_debug_part.CFrame *= CFrame.new(0, 0, -rayDirection.Magnitude/2)
end
end
--Do space query
--doesn't seem to be the problem
local insidePart = workspace:GetPartBoundsInBox(wheelAttachmentCFrame, Vector3.new(0.1,0.1,0.1), overlapParams)
if #insidePart > 0 then
print("Inside")
end
end
return netTranslationForce , netTorque
end
-- Function to normalize a vector
function normalizeVector(vx, vy, vz)
local magnitude = math.sqrt(vx^2 + vy^2 + vz^2)
return vx / magnitude, vy / magnitude, vz / magnitude
end
-- Function to calculate moment of inertia for an arbitrary axis, from chatgpt not sure if correct
function calculateGeneralizedMomentOfInertia(mass, width, height, depth, axisOfRotation : Vector3)
--Calculate the moments of inertia along the principal axes
local Ixx = (1/12) * mass * (height^2 + depth^2)
local Iyy = (1/12) * mass * (width^2 + depth^2)
local Izz = (1/12) * mass * (width^2 + height^2)
-- The inertia tensor in matrix form (assuming principal axes aligned with the cuboid)
local inertiaTensor = {
{Ixx, 0, 0},
{0, Iyy, 0},
{0, 0, Izz}
}
-- Normalize the arbitrary axis vector
local vx, vy, vz = axisOfRotation.X, axisOfRotation.Y, axisOfRotation.Z
-- Calculate moment of inertia along the arbitrary axis
--local I_axis = vx * (vx * Ixx + vy * 0 + vz * 0)
-- + vy * (vx * 0 + vy * Iyy + vz * 0)
-- + vz * (vx * 0 + vy * 0 + vz * Izz)
local I_axis = vx * (vx * inertiaTensor[1][1] + vy * inertiaTensor[1][2] + vz * inertiaTensor[1][3]) +
vy * (vx * inertiaTensor[2][1] + vy * inertiaTensor[2][2] + vz * inertiaTensor[2][3]) +
vz * (vx * inertiaTensor[3][1] + vy * inertiaTensor[3][2] + vz * inertiaTensor[3][3])
--for a sphere
--local I_axis = (2/5) * mass * (width/2)^2
return I_axis
end
function module.physicsSubstep(chassisPrimaryPart : BasePart, SpringVectorForceDictionary, params, MaxSpringLength, calculatedStiffnessValue, dampingCoefficient, timeStep, rayVisualDictionary, substepDivisions, overlapParams, currentRobloxAngularAcceleration)
local chassisMass = chassisPrimaryPart.AssemblyMass
local simulatedSteppedFrameChassisData = {
CurrentCFrame = chassisPrimaryPart.CFrame;--from primary part
CenterOfMassOffset = chassisPrimaryPart.AssemblyCenterOfMass - chassisPrimaryPart.Position;
AssemblyAngularVelocity = chassisPrimaryPart.AssemblyAngularVelocity;
AssemblyVelocity = chassisPrimaryPart.AssemblyLinearVelocity;
}
local netSpringForce, netSpringMoment = module.calculateNetSpringForce(simulatedSteppedFrameChassisData, SpringVectorForceDictionary, params, MaxSpringLength, calculatedStiffnessValue, dampingCoefficient, rayVisualDictionary, overlapParams)
local netMoment = netSpringMoment
local netForce = netSpringForce - chassisMass*workspace.Gravity*Vector3.yAxis
local linearAcceleration = netForce/chassisMass
--Wrong
local width = chassisPrimaryPart.Size.X
local height = chassisPrimaryPart.Size.Y
local depth = chassisPrimaryPart.Size.Z
local momentOfInertia = calculateGeneralizedMomentOfInertia(chassisMass, width, height, depth, simulatedSteppedFrameChassisData.AssemblyAngularVelocity.Unit)
local angularAcceleration = netMoment/momentOfInertia
--print(momentOfInertia, angularAcceleration.Magnitude)
if angularAcceleration ~= angularAcceleration then
angularAcceleration = Vector3.zero
end
--Perform Semi-Implicit Euler integrator in half the time step.
local simulationTimeStep = timeStep/substepDivisions
local averageSpringMoment = netSpringMoment
local averageSpringForce = netSpringForce
local newStepForwardMoment = netSpringMoment
for i = 1, substepDivisions - 1 do
local momentOfInertia = calculateGeneralizedMomentOfInertia(chassisMass, width, height, depth, simulatedSteppedFrameChassisData.AssemblyAngularVelocity.Unit)
local angularAcceleration = newStepForwardMoment/momentOfInertia
--apply simulated acceleration
simulatedSteppedFrameChassisData.AssemblyVelocity += linearAcceleration*simulationTimeStep
if angularAcceleration == angularAcceleration then
simulatedSteppedFrameChassisData.AssemblyAngularVelocity += angularAcceleration*simulationTimeStep
local angleVel = simulatedSteppedFrameChassisData.AssemblyAngularVelocity
simulatedSteppedFrameChassisData.AssemblyAngularVelocity = angleVel * math.exp(-0.06*simulationTimeStep)
end
--Change rotation and position
--Do you rotate first or change position first? shouldn't matter
--simulatedSteppedFrameChassisData.CurrentCFrame += simulatedSteppedFrameChassisData.AssemblyVelocity*simulationTimeStep
local angularVelocityAtThisTime = simulatedSteppedFrameChassisData.AssemblyAngularVelocity
simulatedSteppedFrameChassisData.CurrentCFrame += simulatedSteppedFrameChassisData.AssemblyVelocity*simulationTimeStep
--This is the issue? yep causes simulated chassis to teleport far away
if angularVelocityAtThisTime.Unit == angularVelocityAtThisTime.Unit then --NAN check
local rotationalDisplacement = simulatedSteppedFrameChassisData.AssemblyAngularVelocity*simulationTimeStep
if rotationalDisplacement.Unit == rotationalDisplacement.Unit then --another NAN check
local rotation = CFrame.fromAxisAngle(angularVelocityAtThisTime.Unit, rotationalDisplacement.Magnitude)
local initialPosition = simulatedSteppedFrameChassisData.CurrentCFrame.Position
local newRotationCF = (rotation*simulatedSteppedFrameChassisData.CurrentCFrame).Rotation
local newCFrame = newRotationCF+initialPosition
simulatedSteppedFrameChassisData.CurrentCFrame = newCFrame
end
--else
-- print("No rotation")
--end
end
--local part = Instance.new("Part")
--part.FrontSurface = Enum.SurfaceType.Motor
--part.BrickColor = BrickColor.Red()
-- part.Anchored = true
-- part.CanCollide = false
-- part.CanQuery = false
--part.Size = Vector3.new(1,1,1)
--part.CFrame = simulatedSteppedFrameChassisData.CurrentCFrame
-- part.Parent = workspace
-- game.Debris:AddItem(part,0.2)
--Recalculate spring force
local newNetSpringForce, newNetSpringMoment = module.calculateNetSpringForce(simulatedSteppedFrameChassisData, SpringVectorForceDictionary, params, MaxSpringLength, calculatedStiffnessValue, dampingCoefficient, rayVisualDictionary, overlapParams, true)
newStepForwardMoment = newNetSpringMoment
--average force
--local difference = newNetSpringForce - netSpringForce
--local momentDifference = newNetSpringMoment - netSpringMoment
----print(momentDifference.Magnitude)
--averageSpringForce += -difference
--averageSpringMoment += -momentDifference
--print(newNetSpringMoment.Magnitude)
--if newNetSpringMoment ~= newNetSpringMoment then
-- --print("THIS IS IT!")
-- --print("Chassis data: ", simulatedSteppedFrameChassisData)
--else
--end
averageSpringMoment = (averageSpringMoment + newNetSpringMoment)
averageSpringForce = (averageSpringForce + newNetSpringForce)
end
averageSpringForce /= substepDivisions
averageSpringMoment /= substepDivisions
return averageSpringForce, averageSpringMoment, simulatedSteppedFrameChassisData
--return netSpringForce, netSpringMoment
end
function module.getNetForceNoSubstep(chassisPrimaryPart : BasePart, SpringVectorForceDictionary, params, MaxSpringLength, calculatedStiffnessValue, dampingCoefficient, timeStep, rayVisualDictionary, linearAcceleration, angularAcceleration)
local chassisMass = chassisPrimaryPart.AssemblyMass
local simulatedSteppedFrameChassisData = {
CurrentCFrame = chassisPrimaryPart.CFrame;--from primary part
CenterOfMassOffset = chassisPrimaryPart.AssemblyCenterOfMass - chassisPrimaryPart.Position;
AssemblyAngularVelocity = chassisPrimaryPart.AssemblyAngularVelocity;
AssemblyVelocity = chassisPrimaryPart.AssemblyLinearVelocity;
}
local netSpringForce, netSpringMoment = module.calculateNetSpringForce(simulatedSteppedFrameChassisData, SpringVectorForceDictionary, params, MaxSpringLength, calculatedStiffnessValue, dampingCoefficient, rayVisualDictionary)
return netSpringForce, netSpringMoment
end
return module
--normal script
local model = script.Parent.Parent
local ChassisSuspensionPhysicsSubstep = require(script.ChassisSuspensionPhysicsSubstep)
local chassis_primary_part = script.Parent
--chassis_primary_part.Shape = Enum.PartType.Ball
chassis_primary_part.Transparency = 1
chassis_primary_part.Size = Vector3.new(5,5,5)
warn("Mass: ", chassis_primary_part.AssemblyMass)
local collisionPart = Instance.new("Part")
collisionPart.Size = Vector3.new(6.2,0.5,11)
collisionPart.Massless = true
collisionPart.Anchored = true
collisionPart.CFrame = chassis_primary_part.CFrame
collisionPart.Parent = chassis_primary_part
model.PrimaryPart = collisionPart
local weld = Instance.new("WeldConstraint")
weld.Part0 = collisionPart
weld.Part1 = chassis_primary_part
weld.Parent = chassis_primary_part
chassis_primary_part.Anchored = false
collisionPart.Anchored = false
--chassis_primary_part.Size = Vector3.new(1,1,1)
local function getPartVolume(part)
local size = part.Size
local volume = size.X * size.Y * size.Z
return volume
end
local function setPartMass(part, mass)
local partProperties = part.CustomPhysicalProperties or PhysicalProperties.new(part.Material)
--local partProperties = part.CustomPhysicalProperties
local _, b, c, d, e =
partProperties.Density, partProperties.Friction, partProperties.Elasticity, partProperties.FrictionWeight, partProperties.ElasticityWeight
local newDensity = mass / getPartVolume(part)
part.CustomPhysicalProperties = PhysicalProperties.new(newDensity, b, c, d, e)
end
--setPartMass(chassis_primary_part, 680)
warn("Mass: ", chassis_primary_part.AssemblyMass)
local wheel_positions = {
front_left = Vector3.new(-2.75, 0, -5),
front_right = Vector3.new(2.75, 0, -5),
back_left = Vector3.new(-2.75, 0, 5),
back_right = Vector3.new(2.75, 0, 5)
}
local attach = Instance.new("Attachment", chassis_primary_part)
local NetForceVectorForce = Instance.new("VectorForce")
NetForceVectorForce.RelativeTo = Enum.ActuatorRelativeTo.World
NetForceVectorForce.Attachment0 = attach
NetForceVectorForce.ApplyAtCenterOfMass = true
NetForceVectorForce.Parent = chassis_primary_part
local NetMoment = Instance.new("Torque")
NetMoment.RelativeTo = Enum.ActuatorRelativeTo.World
NetMoment.Attachment0 = attach
NetMoment.Parent = chassis_primary_part
--Setup forces
local SpringVectorForceDictionary = {}
local WheelDataTable = {}
local PreviousSpringLengthDictionary = {}
for wheel_name, original_position in pairs(wheel_positions) do
PreviousSpringLengthDictionary[wheel_name] = 2.1
--original_position = Vector3.yAxis
local chassis_cframe = chassis_primary_part.CFrame
local ray_origin = chassis_cframe:ToWorldSpace(CFrame.new(original_position))
local attachment = Instance.new("Attachment")
attachment.Visible = true
attachment.WorldPosition = ray_origin.Position
attachment.Parent = chassis_primary_part
attachment.WorldPosition = ray_origin.Position
local billboardGui = Instance.new("BillboardGui")
billboardGui.Enabled = false
billboardGui.Size = UDim2.fromOffset(100, 50)
billboardGui.ResetOnSpawn = false
billboardGui.Adornee = attachment
billboardGui.AlwaysOnTop = true
billboardGui.Parent = attachment
local textLabel = Instance.new("TextLabel")
textLabel.Size = UDim2.fromOffset(100, 50)
textLabel.Parent = billboardGui
local vectorForce = Instance.new("VectorForce")
vectorForce.Force = Vector3.zero
vectorForce.Visible = true
vectorForce.RelativeTo = Enum.ActuatorRelativeTo.World
vectorForce.ApplyAtCenterOfMass = false
vectorForce.Attachment0 = attachment
vectorForce.Parent = attachment
SpringVectorForceDictionary[wheel_name] = vectorForce
WheelDataTable[wheel_name] = {}
end
--Debug raycast suspension
local raycast_visualization_dictionary = {}
for wheel_name, original_position in pairs(wheel_positions) do
local debugPart = Instance.new("Part")
debugPart.Name = "RaycastDebugPart"
debugPart.BrickColor = BrickColor.Red()
debugPart.Anchored = true
debugPart.CanCollide = false
debugPart.CanQuery = false
debugPart.Size = Vector3.new(1,1,1)
debugPart.Parent = workspace
raycast_visualization_dictionary[wheel_name] = debugPart
end
local debugPart = Instance.new("Part")
debugPart.Name = "ChassisDebugPart"
debugPart.BrickColor = BrickColor.Red()
debugPart.Anchored = true
debugPart.CanCollide = false
debugPart.CanQuery = false
debugPart.Size = Vector3.new(3,2,8)
debugPart.Parent = workspace
raycast_visualization_dictionary["ChassisSimulation"] = debugPart
local params = RaycastParams.new()
params.FilterDescendantsInstances = {chassis_primary_part}
local RunService = game:GetService("RunService")
if not workspace:GetAttribute("DampingRatio") then
workspace:SetAttribute("DampingRatio", 0.8)
end
--Max raycast length, less = more stiffness
if not workspace:GetAttribute("spring_free_length_ratio") then
workspace:SetAttribute("spring_free_length_ratio", 1.5)
end
if not workspace:GetAttribute("suspension_desired_length") then
workspace:SetAttribute("suspension_desired_length", 2.1) --Lower values of length = more stiffness = more force = more unstable
end
local xZ_FrictionVelocity = Instance.new("BodyVelocity")
xZ_FrictionVelocity.Velocity = Vector3.new(0,0,0)
xZ_FrictionVelocity.Parent = chassis_primary_part
local PastLinearVelocity = Vector3.zero
local PastAngularVelocity = Vector3.zero
local LinearAcceleration = Vector3.zero
local AngularAcceleration = Vector3.zero
local overlapParams = OverlapParams.new()
overlapParams.FilterDescendantsInstances = {chassis_primary_part}
local pastDt = 1/60
RunService.Stepped:Connect(function(time, dt)
LinearAcceleration = (chassis_primary_part.AssemblyLinearVelocity - PastLinearVelocity)
AngularAcceleration = (chassis_primary_part.AssemblyAngularVelocity - PastAngularVelocity)/dt
PastLinearVelocity = chassis_primary_part.AssemblyLinearVelocity
PastAngularVelocity =chassis_primary_part.AssemblyAngularVelocity
local assemblyMass = chassis_primary_part.AssemblyMass
--Calculate spring constant, for given hipheight against gravity, from equilibrium
local weight = assemblyMass*workspace.Gravity
local springFreeLengthRatio = workspace:GetAttribute("spring_free_length_ratio")
local suspensionDesiredLength = workspace:GetAttribute("suspension_desired_length")
local ratio = suspensionDesiredLength*(springFreeLengthRatio-1)
local calculatedStiffnessValue = weight/ratio/4
--Calculating damping coefficient from ratio
local totalStiffness = calculatedStiffnessValue * 4
local dampingRatio = workspace:GetAttribute("DampingRatio")
local dampingCoefficient = (dampingRatio * (2*math.sqrt(totalStiffness*assemblyMass))) / 4
--Since we have 4 wheels
--local totalStiffness = calculatedStiffnessValue * 4
local totalDampening = 4*dampingCoefficient
--local dampingRatio = totalDampening/(2*math.sqrt(totalStiffness*assemblyMass))
--warn("Damping Ratio", dampingRatio) --Should be 0.2 - 0.6
local totalStiffness = calculatedStiffnessValue * 4
--local alpha = (1/dt)*math.sqrt(assemblyMass/(totalStiffness))
--warn("Alpha: ", math.round(alpha*100)/100)
local MaxSpringLength = suspensionDesiredLength*springFreeLengthRatio
local wheelOffset = Vector3.new(0,0,0)
local onGround = false
local substepDivisions = 5
local timeStepDT = dt
local averageForce, averageMoment = ChassisSuspensionPhysicsSubstep.physicsSubstep(chassis_primary_part,SpringVectorForceDictionary, params, MaxSpringLength, calculatedStiffnessValue, dampingCoefficient, timeStepDT, raycast_visualization_dictionary, substepDivisions, overlapParams, AngularAcceleration)
--averageMoment = Vector3.new(0,100000,0)
--averageMoment = -averageMoment
--print(averageForce.Magnitude, averageMoment.Magnitude)
--chassis_primary_part.CFrame = simulatedSteppedFrameChassisData.CurrentCFrame
--print("New moment")
--print(averageMoment.Magnitude)
--if averageMoment.Magnitude > 0.0001 then
-- local part = Instance.new("Part")
-- part.Anchored = true
-- part.CanCollide = false
-- part.CanQuery = false
-- local pos = chassis_primary_part.Position
-- local len = averageMoment.Magnitude/100000*10
-- part.CFrame = CFrame.lookAt(pos, pos - averageMoment)*CFrame.new(0,0,len/2)
-- part.Size = Vector3.new(0.1,0.1, len)
-- part.Parent = workspace
-- game.Debris:AddItem(part,0.1)
--end
--print(averageMoment.Magnitude)
NetForceVectorForce.Force = averageForce
--NetMoment.Torque = averageMoment
NetMoment.Torque = averageMoment
--chassis_primary_part:ApplyAngularImpulse(averageMoment*dt)
--print(NetMoment.Torque.Magnitude, NetForceVectorForce.Force.Magnitude)
--For friction XZ, for debugging or else it slides everywhere
--local XZ_FrictionForce = Vector3.new(25000,0,25000)*5
local XZ_FrictionForce = Vector3.new(1,0,1)*25000*10
xZ_FrictionVelocity.MaxForce = XZ_FrictionForce
end)