HowTo: Physics-Based TweenService

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:

  1. Anchor a part
  2. Change the Velocity (now AssemblyLinearVelocity)
  3. 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)

Roblox Conveyor Belt

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

146 Likes

I’m not sure this is how you project works but do you think this could work for bullets that fall after being tweened to the hit.pos or is this more of a solid map kinda physics

3 Likes

This tutorial is great, but wouldn’t using Stepped be more efficient, as renderstepped is designed to run before rendering the frame on a client?, also stepped runs before the physics are calculated, and renderstepped is not avaible from the server.

2 Likes

If you have a fairly large projectile that hits other objects and you want to transfer the kinetic energy like Newton’s Cradle, setting the AseemblyLinearVelocity might help.

However, for bullets, you might want to use raycasts and only tween bullets for visual effect.

In short, bullets wouldn’t be a very great use-case for this

1 Like

For performance reasons explained in the post, you’d want to do this on each client rather than the server.

However, if you really want to do this on the Server, using .Stepped comes with some warnings in the documentation:

There is no guarantee that functions connected to this event will fire at the exact same time, or in any specific order. For an alternative where the priority can be specified, see RunService:BindToRenderStep().

In my examples, we get around this by setting the RenderPriority to 99, which is right before input. If you want to do this on the server, it might be appropriate to use RunService.PreSimulation to catch the engine right before it runs physics calculations. Note that I haven’t really tested that, so do with that information what you will.

-- Server code (please don't use this on the server)
RunService.PreSimulation:Connect(onRenderStepped) -- Connect to the example function above

the problem is that even if you dont care about cheaters gaining ownership and manipulating the boxes, what if the boxes are designed to be left alone there.

if no client can calculate the physics for props on platforms it would just break, But i see there is no easy solution now that you tell me this.

I’m mostly having trouble understanding your question but there are definitely solutions that satisfy replication, cheating and generally working

Why not use .Stepped since that also runs before physics? Binding all of these moving platforms on RS creates significantly more performance drawbacks per frame than it would with Heartbeat or Stepped.

Id only use RenderStepped for camera or input related work.

So I went to go mess around and find out why there’s warnings all over .Stepped… As it turns out, the docs weren’t kidding when they said that RunService.Stepped can’t guarantee the order it’s fired at

Correction: .Stepped does not result in glitchy behavior when arguments for dt are passed in the correct order. Thank you Stratiz.

If looking for an event of RunService to connect to, RunService.PreSimulation is probably the best. I had replied to @CCTVStudios about this but I just now tested it and I can confirm it works great.

I’d still argue that we’re using BindToRenderStep at the appropriate RenderPriority. If you look at how BindToRenderStep is being used in the examples, you can see that the RenderPriority is set to 99.

The thinking behind this is that 99 renders right before input, allowing the computer to run our code & calculate the visual location of the model, then accept input, then render camera, then simulate character, and then simulate physics. That way you can hypothetically walk on a moving platform.

2 Likes

Cool thread! Reminds me of how it was done here: Kinematic Physics Module - Simple part movement in server scripts

This result doesnt make sense. Can I see the code snippet you’re using to test stepped? I suspect you’re using the wrong stepped argument. Remember that stepped’s first return argument isnt delta time, and is instead the second argument in the callback.

Also the warnings in the docs are saying that the order in which you connect to the event does not guarentee that will be the same order the connected functions are fired in, not that stepped runs at random times.

Probably better to just cframe model root parts using bulkmoveto. PivotTo is like the slowest way to move stuff

You’re correct, I assumed the first argument was dt and not time

Did some testing and I’m not sure if there are any conclusive gains from using .Stepped over .PreSimulation
Feels like preference at this point

Currently testing BulkMoveTo over PivotTo for tyridge

So I went ahead and stress-tested my game by turning off StreamingEnabled, which is arguably the most important optimization in my game

Part Count: After & Before StreamingEnabled
I must preface that the following tests are done with StreamingEnabled OFF in order to stress test. I have many moving models in my game and turning StreamingEnabled off jumps the number from

9 moving models with 50 moving parts (StreamingEnabled)
to
85 moving models with 771 moving parts (StreamingDisabled)

PivotTo vs BulkMoveTo
Here are my testing results with StreamingEnabled OFF:
PivotTo (No AssemblyLinearVelocity) frame time: ~5ms
BulkMoveTo (No AssemblyLinearVelocity) frame time: ~4.5ms

While BulkMoveTo can shave off 0.5ms - 1ms off of frame times, there are magnitudes larger performance hogs in the microprofiler unrelated to moving the model. For instance, the for loop to set set the AssemblyLinearVelocity of 771 parts (extreme case) adds about 1ms per frame…

LDL PGS Solver Performance
Beyond this, it turns out that the more unanchored parts you have being influenced by a moving model, the harder the LDLPGSSolver is working. Before the 200 parts spawn in the video, physicsStepped took around ~1ms. After the 200 parts spawn, the PGS solver jumps all the way up to ~17ms. That’s really pushing it considering you have about 16.6ms per frame for a 60hz framerate.

Conclusions
Simply deleting the parts means that you get performance back. Interestingly, if I turn back on StreamingEnabled, the frame time of physicsStepped goes from ~17ms to ~12ms. Regardless, this seems to be a function of physics being calculated for unanchored parts by LDLPGSSolver.

In short, while BulkMoveTo can make small improvements to frame time, there are much larger performance gains to be realized from the following:

  1. Whitelist models that need to use velocity & angular velocity influence
  2. Use a StreamingEnabled radius to cut back on moving parts on the client
5 Likes

Previously for moving assemblies with physics on, I’ve Tweened a rigid AlignPosition constraint on an unanchored PrimaryPart that everything else is welded to. How does this method perform compared to what you’ve done?

1 Like

There’s no documentation on how the performance of AlignPosition can be measured in the microprofiler. They may be using PID like the old BodyPosition mover or they migrated this into the PGS solver such that it solves for the position given a series of mathematical constraints. Not sure. Bottom line is that it’s very performant as it stands.

The methods stated here are no more performant than simply setting 2 properties on every BasePart within a model: AssemblyLinearVelocity and AssemblyAngularVelocity. After these properties are set, the script is done with its job. The rest is handed to Roblox’s LDL PGS Solver to handle physics.

My speculation is that both would perform equally well, but the AlignPosition my or may not have an edge being more tightly integrated into the physics solver.

Recently I have discovered some “low-accuracy, high-precision” issues that could be attributed to velocity calculations being 1 frame too late. So I am actively experimenting with solving for AssemblyLinearVelocitiess 1 frame ahead of the current frame given that the tween path is already known and we can peek future positions several milliseconds ahead of the current frame to solve for future AssemblyLinearVelocity. Standby for that report.

Would there be a way to make the tween reverse this way? Your videos show that, but I simply cannot figure it out for the life of me.

Not to diminish the great work you have done on this , but I think you should point out that this isn’t really tween based physics but a new type of tween physics lite. :thinking:

The only reason I make this nitpick is that your example videos using the tween physics you created; the physics actually looks wrong. :face_with_raised_eyebrow:
When your platform is moving around, the parts should be falling off due to the variables of friction, mass, velocity, etc. Your tween physics appears to be ignoring a lot of variables where the parts just land and don’t move relative to those variables of the moving platform. So then, the reason your tween physics is faster might be because it is ignoring a lot of stuff that the Roblox built-in physics solver has to account for. :wink:

Yes, there is a way to play the tween in reverse and you would do this by the alpha value you pass to TweenService:GetValue(alpha, easingDirection, easingStyle)

Think of alpha as a percent completion of the tween. In fact, you can multiply alpha by 100 to get a percentage. alpha = 0 means 0%, alpha = 1 means 100%.

If you want to keep the easingDirection the same and play the tween in reverse, instead of incrementing the alpha from 0 → 1, you have to decrement the alpha from 1 → 0.

-- Forward
local startCFrame = CFrame.new(0,0,0)
local endCFrame = CFrame.new(0, 10, 0)

local tweenTime = 10 -- seconds
local alpha = 0
local runServiceConnection
runServiceConnection = RunService.PreSimulation:Connect(function(deltaTime)
     -- deltaTime is the time in seconds since the last frame
     -- We need to know what percent of tweenTime this deltaTime was to increment alpha
     local alphaStepped = deltaTime / tweenTime
     alpha += alphaStepped
     if alpha >= 1 then 
          alpha -= 1 -- Reset alpha back to 0 with remainder
     end

     local tweenAlpha = TweenService:GetValue(alpha, Enum.EasingDirection.InOut, Enum.EasingStyle.Sine)

     -- Find the new CFrame between start and end based on TweenAlpha
     local newCFrame = startCFrame:Lerp(tweenAlpha, endCFrame)

     -- Set the CFrame of the part here (or use PivotTo)
     part.CFrame = newCFrame
end)

Then here is backwards

-- Backwards
local startCFrame = CFrame.new(0,0,0)
local endCFrame = CFrame.new(0, 10, 0)

local tweenTime = 10 -- seconds
local alpha = 1
local runServiceConnection
runServiceConnection = RunService.PreSimulation:Connect(function(deltaTime)
     -- deltaTime is the time in seconds since the last frame
     -- We need to know what percent of tweenTime this deltaTime was to decrement alpha
     local alphaStepped = deltaTime / tweenTime
     alpha -= alphaStepped
     if alpha <= 0 then 
          alpha += 1 -- Reset alpha back to 1 with remainder
     end

     local tweenAlpha = TweenService:GetValue(alpha, Enum.EasingDirection.InOut, Enum.EasingStyle.Sine)

     -- Find the new CFrame between start and end based on TweenAlpha
     local newCFrame = startCFrame:Lerp(tweenAlpha, endCFrame)
     
     -- Set the CFrame of the part here (or use PivotTo)
     part.CFrame = newCFrame
end)
2 Likes

Aha, but that’s where you’re mistaken. You’re correct that there is a physics error here, but you haven’t found it. I’ll be addressing it in a separate reply. Back to this topic, though:

The kinetic energy transfer from the tweening model to touching assemblies is completely and 100% calculated by Roblox’s PGS solver. In no way does this code ever manipulate the physics of touching assemblies directly. If you read the code with the intent to understand, you would see this.

Because it relies on Roblox’s PGS solver, this method completely respects all physical properties including density, friction, and elasticity.

The above test shows parts being dropped with custom physical properties with high elasticity (bounciness), low density (lightweight), and low friction (slippery). This directly refutes the claim.

1 Like