Trying to create Ray-cast Suspension; Editable, Open, Stable and Optimized

THE TOPIC IS FOCUSING ON RAYCAST SUSPENSION NOT ROBLOX’S SUSPENSION CONSTRAINTS, AND IS AN INDEPENDANT SYSTEM.

This is approximately my 7th time trying to achieve an Editable, Open, Stable and Optimized Ray-cast Suspension, I’ve seen so many developers do this saying its simple which makes me clench my fists and want to absolutely obliterate my setup, but I don’t have the energy nor the ability to replace it. I’ve created this post to work together to solve this problem; Jittering, Flinging, Odd Events, etc.

Before I start packing my bags to go to Godot and becoming an actual Solo Dev, let me give this one more full on try.

:face_with_raised_eyebrow: Why Choose Ray-cast/Independently Created Suspension?

  • Full Customization & Control
  • Optimized Performance (Roblox’s spring constraints has features we do not need most of the time)
  • More Realistic & Versatile Behavior
  • Compatibility & Expandability (unlike Spring Constraints)
  • Easier Debugging & Troubleshooting

:mag: Researched Articles and Resources

Briefly, making suspension In other games does not require much thinking and effort since the Physics engine is quite linear and predictable. For example this is what I would do for a drivable body,

--PSEUDOCODE
VEHICLE_BODY = ...
WHEEL_POINTS = {...} OR [...]

UPDATE:
  FOR EACH WHEEL_POINTS (POINT):
     POINT:ApplyForce(UPVECTOR*STRENGTH*math.clamp(rayDistance/MAXLENGTH, 0, 1)

But much more calculations are involved just for some normal looking suspension in roblox.

Resources I’ve Used for Ray-cast Based Suspension


:stop_sign: The Problems

But for some fudging reason, I need to know copious amounts of rocket science just to make it stably float on a flat surface and nothing else; Its very complicated to achieve pleasing but simple results. Here are some factors/facts and results about it:

  • Impulse Sucks, Vector Forces do nothing but are the best way to go and Body Velocities/Gyros are deprecated (buggy)
  • Friction/Traction Forces on the Right-Vector are not accurate, for whatever reason.
  • Adding other features like changeable dampening breaks the system completely.
  • Suspension Results are either to Upright-ish (not realistic) or very buggy and unrealistic in certain cases, for example your car flipping onto its side and instantly getting back onto its wheels

Visual Examples (Projects I was trying to make)


:construction: Project Build

This is where the research is started and concluded.

Project Overview

Car Hierarchy

Functionality
Functionality

Main Script

--================
--> SERVICES
--================

local RunService = game:GetService("RunService")

--================
--> Set-up
--================
local CarModel = workspace.Car
local Root = CarModel.PrimaryPart or CarModel:WaitForChild("Root")

--================
--> MODULES
--================
local ValueHandler = require(script.ValueHandler)
local SuspensionModule = require(script.SuspensionModule)
local GizmoModule = require(script.GizmoModule)

local Config = ValueHandler.New(CarModel:FindFirstChild("Configuration"))

--=====================
--> OBJECTS AND VALUES
--=====================

local WheelAttachments = Root:GetChildren()

local CachedWheelData = {}
local THRUST_OFFSET_Z = 3
local THRUST_OFFSET_Y = -1
local MIN_LENGTH = 1
local TORQUE = 18

local Velocity = Vector3.zero

-- Properties
local SpringStiffness = Config:GetValue("SpringStiffness")
local SpringDamping = Config:GetValue("SpringDamping")
local SpringLength = Config:GetValue("SpringLength")
local WheelRadius = Config:GetValue("WheelRadius")
local WheelStiffness = Config:GetValue("WheelStiffness")
local WheelFriction = Config:GetValue("WheelFriction")
local WheelDamping = Config:GetValue("WheelDamping")

local Gas = Config:GetValue("Gas")

--=====================
--> FUNCTIONS
--=====================
GizmoModule:Enable()

local function updateProperties()
	SpringStiffness = Config:GetValue("SpringStiffness")
	SpringDamping = Config:GetValue("SpringDamping")
	SpringLength = Config:GetValue("SpringLength")
	WheelRadius = Config:GetValue("WheelRadius")
	WheelStiffness = Config:GetValue("WheelStiffness")
	WheelFriction = Config:GetValue("WheelFriction")
	WheelDamping = Config:GetValue("WheelDamping")
	
	Gas = Config:GetValue("Gas")
end

local function updateWheel(attachment:Attachment, delta)
	
	local WheelRaycast = SuspensionModule.Raycast(attachment.WorldPosition, -attachment.WorldCFrame.UpVector, SpringLength, WheelRadius, attachment.CFrame.LookVector, {CarModel})
	local VectorForce = attachment:FindFirstChild("VectorForce")
	
	local PointCFrame = attachment.WorldCFrame
	local PointPosition = attachment.WorldPosition
	
	local RightVector = PointCFrame.RightVector
	local UpVector = PointCFrame.UpVector
	local FowardVector = PointCFrame.LookVector
	
	if not VectorForce or not WheelRaycast then
		if not WheelRaycast and VectorForce then
			VectorForce.Force = Vector3.zero
		end
		return
	end
	
	local RayDistance = WheelRaycast.Distance
	
	-- Steering/Friction
	local MaxFrictionForce = WheelFriction * WheelRaycast.Normal
	local RightAxisForce = MaxFrictionForce
	
	-- Suspension
	local CompressionRatio = 1-(RayDistance/(SpringLength+WheelRadius))
	local PreviousCompressionRatio = CachedWheelData[attachment.Name] and CachedWheelData[attachment.Name].CompressionRatio or 0
	
	local UpAxisForce = SpringStiffness * CompressionRatio
	
	-- Gas/Thrust/Braking
	
	local FowardAxisForce = 0 -- keeping focus on suspension..
	
	VectorForce.Force = (RightVector * RightAxisForce) + (UpVector * UpAxisForce) + (FowardVector * FowardAxisForce)
	
	CachedWheelData[attachment.Name] = {
		CompressionRatio = CompressionRatio,
		SurfaceImpactPoint = WheelRaycast.Position,
		SurfaceImpactNormal = WheelRaycast.Normal
	}
end

--=====================
--> CONNECTIONS
--=====================

Config.ValueChanged:Connect(function(valueName, newValue)
	updateProperties()
end)

RunService.Heartbeat:Connect(function(delta)
	for _, attachment in WheelAttachments do updateWheel(attachment,delta) end
	
	--local Gas = Config:GetValue("Gas")
	
	local DriveOutput = Gas * TORQUE
	
	local RootCFrame = Root.CFrame
	local RootPosition = Root.Position

	local RightVector = RootCFrame.RightVector
	local UpVector = RootCFrame.UpVector
	local FowardVector = RootCFrame.LookVector
	
	-- Gas/Braking
	Root:ApplyImpulseAtPosition((FowardVector * DriveOutput), Root.Position + (FowardVector * THRUST_OFFSET_Z) + (UpVector * THRUST_OFFSET_Y))
end)

Suspension Module

--================
--> Set-up
--================
local Gizmos = require(script.Parent.GizmoModule)

local suspension_module = {}


--================
--> Raycasting
--================
function suspension_module.Raycast(start : Vector3, direction : Vector3, length:number, wheelRadius:number, foward, ignore:{any}) : RaycastResult
	local raycast_params = RaycastParams.new()
	raycast_params.FilterType = Enum.RaycastFilterType.Exclude
	raycast_params.FilterDescendantsInstances = ignore
	
	local ray_end = direction*length
	local raycast_result = workspace:Raycast(start, direction * (length+wheelRadius), raycast_params)
	
	Gizmos.Point(start, 0.1)
	
	if raycast_result then
		Gizmos.Arrow(start, raycast_result.Position, 0.5)
		
		local WheelCFrame = CFrame.new(raycast_result.Position) * CFrame.new(0,wheelRadius,0) * CFrame.lookAt(start, start+foward).Rotation
		Gizmos.Circle(WheelCFrame, wheelRadius, 0.02)
		return raycast_result
	else
		Gizmos.Point(start+ray_end, 0.1)
		Gizmos.Line(start, start+ray_end, 5)
		
		local WheelCFrame = CFrame.new(start+ray_end) * CFrame.new(0,wheelRadius,0) * CFrame.lookAt(start, start+foward).Rotation
		Gizmos.Circle(WheelCFrame, wheelRadius, 0.02)
	end
	
	return false
end

function suspension_module.SphereCast(start : Vector3, direction : Vector3, length : number, radius : numbers, ignore : {any}) : RaycastResult
	local sphere_cast_params = RaycastParams.new()
	sphere_cast_params.FilterType = Enum.RaycastFilterType.Exclude
	sphere_cast_params.FilterDescendantsInstances = ignore
	
	local sphere_cast_result = workspace:Spherecast(start, radius, direction * length, sphere_cast_params)
	
	if sphere_cast_result then
		return sphere_cast_result
	end
	return false
end

function suspension_module.PartCast(start, direction, length, part, ignore) : RaycastResult
	local part_cast_params = RaycastParams.new()
	part_cast_params.FilterType = Enum.RaycastFilterType.Exclude
	part_cast_params.FilterDescendantsInstances = ignore
	
	local part_cast_result = workspace:Shapecast(part, direction * length, part_cast_params)
	
	if part_cast_result then
		return part_cast_result
	end
	return false
end

return suspension_module


Current Project ) Car | Project File (87.5 KB)

Versions


Concluded Project ) No Official Conclusion yet...


Record Keeping
Established Date : 2025-02-21T00:00:00Z
Ended Data : N/A

1 Like

Welp, I guess no results yet… :sad:

Moving a little forward!

May Help those in need, possibly… This script actually works pretty well! This is a general function so you may need to play around with it.


:toolbox: Spring Function

function calculateSpring(origin:Vector3, to:Vector3, delta:number, properties:PropertiesTable) : (Force | Vector3 , CurrentDisplacement | number)
	local toVector = to - origin
	local displacement = math.max(properties.Length - toVector.Magnitude, 0.1)
	
	local velocity = (displacement - properties.lastDisplacement or 0)/delta
	
	local springForce = properties.Mass * properties.Stiffness * displacement
	local dampeningForce = properties.Mass * (properties.Dampening or 0) * velocity
	local netForce = springForce + dampeningForce
	
	return toVector.Unit * netForce, displacement
end

Properties Table
Mass : Number
Stiffness : Number
Dampening : Number (LastDisplacement cant be nil)
Length : Number
LastDisplacement : Number

Resources
I mostly looked around physical science articles and decided to follow the math instead of my head and resoures from other posts and forums - like i should’ve done before.

Force = Mass * Acceleration

Acceleration = ( Final Velocity - Initial Velocity ) / Time Taken

Therefor can be written as:

F = M * ( U - V / Time )


Concluded Formula

Spring = -displacement * strength

DampenedSpring = spring + ( dampening * ( u - v / time ))


:eye: Preview of Results


Although this is far from suspension and for vehicles.

1 Like

Hey there, I’m not sure if it’s any use to you but I have a custom spring module that essentially acts exactly like the Roblox SpringConstraint but uses Lua instead. I created it because I wanted to be able to scale physics fidelity based on distance from player, as well as create and control vehicles from the client and ultimately have no replication on the server at all (I think I can design a more efficient replication algorithm for cars). It works by spawning a “Config” object with attributes that represent the SpringConstraint’s properties. From there, it uses those to simulate Hooke’s law forces on physical VectorForce objects (I originally did ApplyImpuse() but I thought VectorForces might do better because of a refresh rate error I will explain next).

Unfortunately, It doesn’t work perfectly. It needs to have access to the internal 240hz physics refresh rate, but the RunService.Stepped function developers get access to only fires at 60hz. The result is this:

streamable

As a result, I think I am forced to develop a raycast-based chassis myself now. I’m sure you’ve already torn apart the white jeep on the surburban map, but that might be a good place to start. The developer of that car found a way around that 240hz issue, but at the cost of realism.

This also might be of use to you:

1 Like

Analyzing your resource I found some useful information! Thank you for providing your source!


Suspension Force

[ All Forces are applied onto the Wheel’s Centre ]

RELATIVE_VELOCITY_Y = ROOT_TRANSFORM:VectorToObjectSpace(ROOT_VELOCITY).Y

SUSPENSION_FORCE = ROOT_TRANSFORM.UP * ( STRENGTH * (REST_LENGTH - DISTANCE) - DAMPENING * RELATIVE_VELOCITY_Y )

Combined (Best from Results)

SUSPENSION_FORCE = ROOT_TRANSFORM.UP * ( STRENGTH * (REST_LENGTH - DISTANCE) - (DAMPENING * (LAST_COMPRESSION - COMPRESSION/delta))

Rolling Resistance Force

RELATIVE_VELOCITY_Z = ROOT_TRANSFORM:VectorToObjectSpace(ROOT_VELOCITY).Z

RESISTANCE_FORCE = ROOT_TRANSFORM.FOWARD*((RELATIVE_VELOCITY_Z * TOTAL_MASS)*ROLLING_RESISTANCE)

A smart Idea from this developer was to apply friction from the suspension force we calculated which makes so much sense!

Suspension Friction Force

SUSPENSION_FRICTION_FORCE = SUSPENSION_FORCE * AbsVecotor(ROOT:VectorToObjectSpace(Vector3.new(FRICTION, 0, ROLLING_RESISTANCE)))

Wheel Friction Force

RELATIVE_VELOCITY_X = ROOT_TRANSFORM:VectorToObjectSpace(ROOT_VELOCITY).X

FRICTION_FORCE = -ROOT_TRANSFORM.RIGHT*((RELATIVE_VELOCITY_X*TOTAL_MASS)*FRICTION)

I’ll try to apply my findings to the project file, and hope for successful results! As of now we just want to simulate this until we move onto playability, Thank you @bigcrazycarboy!

You could try adding physics substepping which will make the suspensions more stable at lower fps and for higher spring stiffness values.

1 Like

Oh my, yes I’ll look into this. This is a lot of information we need; thank you for this! I’ll update you guys sooner or later, school is in the way.

Project File v0.2 has been uploaded, friction and suspension works a little better now, still needs work see you sooner or later.

1 Like

Hello again, I’ve made progress again but I’ll refrain from posting too much information about the project to keep it secret for the final product! As of now I don’t think I’ll be adding sub stepping as the update loop is connected to a physics process but the process loop works fine too, these will be discovered later on.


Preview Of Results (v1.1.2 | 10/3/2024)

2 Likes