My latest Roblox game entirely relies on TweenService to animate platforms, traps, etc.
However, TweenService, as wonderful of a tool as it is, has one Achilles heel: Physics.
So much so that any sort of horizontal elevator would result in the player sliding off like butter:
Trying to solve this problem got me thinking of the way we used to make conveyor belts in 2010. The steps were simple- documented, even, at one point:
How to create a conveyor belt:
- Anchor a part
- Change the Velocity (now AssemblyLinearVelocity)
- Voila: a conveyor belt!
local conveyorBelt = workspace.ConveyorBelt -- Insert a part named ConveyorBelt into workspace
conveyorBelt.Anchored = true
conveyorBelt.AssemblyLinearVelocity = Vector3.new(0,0,2)
So assuming that AssemblyLinearVelocity influences parts touching the physics assembly, I wondered if we can use this same principle for anything that uses TweenService or :PivotTo() to move. Maybe it’s okay to use TweenService on an elevator after all while guaranteeing that occupants never fall off!
Using AssemblyLinearVelocity + TweenService
Fusing the two is a bit tricky and requires us to ditch the conventional TweenService:Create() and Tween:Play() methods we’re all accustomed to. I know, I know. But you need to hear me out. We need the strict ability to control physics at every discrete frame of a tween. Fortunately, Roblox exposes an API called TweenService:GetValue() so that we can do this:
Lets start with a familiar tween:
-- [Figure 1] Conventional TweenService
local TweenService = game:GetService("TweenService")
local myPart = workspace.Part -- Whatever your part is
local goal = {
CFrame = CFrame.new(0,0,0) -- Whatever your goal is
}
local tweenInfo = TweenInfo.new(1, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true)
local tween = TweenService:Create(myPart, tweenInfo, goal)
tween:Play()
-- To stop:
tween:Stop()
Simple enough. Now, lets take this same tween and make it into a RunService loop with :GetValue()
-- [Figure 2] TweenService using :GetValue()
local TweenService = game:GetService("TweenService")
local RunService = game:GetService("RunService")
local myModel = workspace.Model -- Yes, you can tween models using this method!
local goalCFrame = CFrame.new()
local tweenDuration = 10
local easingStyle = Enum.EasingStyle.Sine
local easingDirection = Enum.EasingDirection.InOut
local _alpha = 0
local _startingCFrame = myModel:GetPivot()
local function onRenderStepped(deltaTime)
-- Calculate new alpha (0-1) based on deltaTime
_alpha += (deltaTime / tweenDuration)
_alpha %= 1 -- This will wrap alpha back to 0 when it hits 1
-- Get α` (alphaPrime) from TweenService:GetValue()
local alphaPrime = TweenService:GetValue(_alpha, easingStyle, easingDirection)
-- Linear interpolation of new CFrame based on alphaPrime
local newCFrame = _startingCFrame:Lerp(goalCFrame, alphaPrime)
-- Pivot the model to the newCFrame!
myModel:PivotTo(newCFrame)
end
-- To start:
RunService:BindToRenderStep("Tween", 99, onRenderStepped)
-- To stop:
RunService:UnbindFromRenderStep("Tween")
:GetValue() is strictly a math transformation. Nothing more, nothing less. Because of this, we have to move our objects ourselves by using :PivotTo() - this is a benefit! By using :PivotTo(), you might have noticed that we can finally use TweenService with models! Not only this, but now we can take our final step where we can influence physics each render step using the conveyor belt method:
-- [Full example] TweenService + Physics
local TweenService = game:GetService("TweenService")
local RunService = game:GetService("RunService")
local myModel = workspace.Model -- Yes, you can tween models using this method!
local goalCFrame = CFrame.new()
local tweenDuration = 10
local easingStyle = Enum.EasingStyle.Sine
local easingDirection = Enum.EasingDirection.InOut
local _alpha = 0
local _startingCFrame = myModel:GetPivot()
local function onRenderStepped(deltaTime)
-- Calculate new alpha (0-1) based on deltaTime
_alpha += (deltaTime / tweenDuration)
_alpha %= 1 -- This will wrap alpha back to 0 when it hits 1
-- Get α` (alphaPrime) from TweenService:GetValue()
local alphaPrime = TweenService:GetValue(_alpha, easingStyle, easingDirection)
-- Linear interpolation of new CFrame based on alphaPrime
local newCFrame = _startingCFrame:Lerp(goalCFrame, alphaPrime)
-- Change velocities on parts to influence physics on parts touching the moving model
-- here lies a bunch of math I solved and tested for you. (you're welcome)
local velocity = (newCFrame.Position - myModel:GetPivot().Position) * (1/deltaTime)
local angularVelocity = Vector3.new((myModel:GetPivot():ToObjectSpace(newCFrame)):ToEulerAngles()) * (1/deltaTime)
for _, part in pairs(myModel:GetDescendants()) do
if part:IsA("BasePart") then
part.AssemblyLinearVelocity = velocity
part.AssemblyAngularVelocity = angularVelocity
end
end
-- Pivot the model to the newCFrame!
myModel:PivotTo(newCFrame)
end
-- To start:
RunService:BindToRenderStep("Tween", 99, onRenderStepped)
-- To stop:
RunService:UnbindFromRenderStep("Tween")
Why RenderStepped and not Heartbeat?
It is very important to step the function on RenderStepped instead of Heartbeat. Heartbeat is done after physics is calculated. At that point, it’d be too late. The physics solver would never have a chance to solve the physics of touching assemblies with our new velocities until the next frame. By running on RenderStepped right before input, we move our parts first and then let the game calculate physics on these new locations.
Performance
As with any animation, it’s strongly advised you animate on the client. This is done so that the server can focus on more important, server-like things. Cosmetic effects like animations always look best on the client. Because of this, it is also wise to set the network ownership of any touching assemblies to the client. You can either let the game handle this or do this manually on the server. Additionally, any parts created by a client script or LocalScript are automatically client-side ownership. Following these practices will give you a butter-smooth performance.
That’s it!
And there you have it! You should now have everything that you need to use TweenService with the native Roblox PGS physics solver!
Through my many years on this platform, physics always seemed to be the tradeoff when using TweenService, but a day iterating some of my code proved that NOT to be the case! I hope this opens up many more possibilities for you as it does me and I hope future developers use this to create something great. Feel free to share as a reply to this post! Also feel free to ask questions!
Happy developing!
Follow @SigmaTechRBLX on X
Sawhorse Interactive - [F] Software Engineer
Supersocial - [F] Software Engineer
Red Manta - [F] Gameplay Programmer