Tweening a model in a viewportframe

Hello, I’m trying to tween a model in a viewportframe however I’m experiencing some issues. I believe the issue is related to the fact that physics don’t work in viewport frames, and consequencly welds don’t either. Without welds, I cannot tween models (as I normally weld all the parts to a single part to tween)

Here is my current code:


local function tweenModel(model, CF)
	print("Model tweening")

	-- Ensure a center part exists
	local center = model:FindFirstChild("CenterPrimaryPart") 
	if not center then
		center = Instance.new("Part")
		center.CFrame = model:GetPivot()
		center.Parent = model
		center.Name = "CenterPrimaryPart"
	end

	-- Weld each part to the center part
	for i,v in pairs(model:GetDescendants()) do
		if v:IsA("BasePart") and v.Name ~= "CenterPrimaryPart" then
				local weld = Instance.new("WeldConstraint")
				weld.Parent = center
				weld.Part0 = center
				weld.Part1 = v
				v.Anchored = false
		end
	end

	-- Tween the center part
	local tween = game:GetService("TweenService"):Create(center, TweenInfo.new(1, Enum.EasingStyle.Linear), {["CFrame"] = CF})
	tween:Play()
	tween.Completed:Wait()
end

Anything would help. And to clarify this code will be used in my upcoming resource for you guys: Record It : Capture and playback recordings in your Roblox experience. I plan to tween objects and models for replaying recorded events to offer smooth framerates and little preformance impact.

2 Likes

I tend to avoid TweenService:Create() for models entirely by essentially mimicking the service with a few changes here and there. In your case, I would manually interpolate between initial and goal CFrames using CFrame:Lerp() and then call :PivotTo() on the model every frame since viewport frames are typically handled locally:

local runSvc = game:GetService'RunService';
local twnSvc = game:GetService'TweenService';

local function tweenModel(model: Model, cf: CFrame, t: number, style: Enum.EasingStyle, dir: Enum.EasingDirection) -- adjustable parameters
	local cf0 = model:GetPivot(); -- initial cframe
	local a = 0; -- alpha [0, 1]
	
	repeat
		model:PivotTo(cf0:Lerp(cf, twnSvc:GetValue(math.min(a, 1), style, dir))); --lerp to goal cframe after adjusting alpha according to easing style
		a += runSvc.RenderStepped:Wait() / t; -- increment alpha
	until a >= 1;
end

Since RenderStepped:Wait() yields the function, we do not need to provide additional code to wait until the tween finishes before continuing.

Anchored objects (objects exempt from physics calculations) work best with manual CFrame manipulation. Welds should never be necessary for anchored objects as individual parts of a model can move in unison using :PivotTo(). Many adept scripters would agree that welding is at best a hacky alternative unless engine physics is necessary.

Since I wrote this on the fly, the function may be poorly structured or not work as expected, so please question it if needed! I tested it without observing any issues.

Are you using a WorldModel in your viewport frame?

no I’m not using this. If I add this will it allow physics? Also should I use the response above?

hey I tried your solution however it doesn’t appear to work.

Here is the script:

local runService = game:GetService("RunService")
local tweenService = game:GetService("TweenService")

local function tweenModel(model, cf) 
	local cf0 = model:GetPivot()
	local a = 0

	repeat
		model:PivotTo(cf0:Lerp(cf, tweenService:GetValue(math.min(a, 1), Enum.EasingStyle.Linear, Enum.EasingDirection.In))) --lerp to goal cframe after adjusting alpha according to easing style
		a += runService.RenderStepped:Wait() / .1 -- increment alpha
	until a >= 1
end

As you can see in the viewportframe, the model isn’t correctly tweening to the new position. However, when I replace your code with pivotTo() it does in fact move.

Yeah using worldmodel should let you do what you’re trying to do. add a worldmodel in the viewport and move the original mode in the worldmodel, then you should be good to go.

I googled what a worldmodel does (as well as tried it out myself) and it doesn’t appear to support physics. what it does support however is animating of humanoids and some other things but is still limited.

For example you still cannot drop unanchored parts. All parts will be stuck anchored essentially.

Can you detail the specifics of your intentions? I’m not entirely sure what object in the viewport needs to move. Is the video using the function or just a PivotTo call?

This is mathematically equivalent to multiplying by 10, meaning that the tween ends in a tenth of a second. I’m unsure if that is your desirable outcome, but parameter t represented tween time; the larger the value, the smaller the increment and therefore the longer it takes for alpha to reach 1.

I am trying to tween any model in a viewportframe with the same behavior of tweenservice. For testing, I recorded my own player character walking around. since my recording system has been designed to handle players too, it should work fine. The only issue seems to be the tweening of the model (consider when I simply use PivotTo() without any kind of tween it works)

These tweens are very quick (ranging from .1 seconds) because its replaying actions (and higher FPS for recoding = very quick tweens)

your current approach doesn’t work properly with my animation script.

This provided context would drastically change my approach. I’m not sure why the function doesn’t work as opposed to a simple PivotTo, but several external factors can skew expected behavior.

As for a new and (possibly) more performant approach, moving a model between recorded positions in brief, constant intervals should not require smoothing/adjustment of the alpha value. Easing styles are difficult to notice especially at higher framerates and sometimes may even introduce clunkiness. A good alternative would be to interpolate rapidly between frames based on FPS:

local runSvc = game:GetService'RunService';

local function replay(model: PVInstance, frames: { CFrame },  fps: number)
	local progress = 0;
	
	runSvc:BindToRenderStep('replay', 0, function(dt)
		local i = math.floor(progress) + 1;
		local cf0 = frames[i];
		local cf1 = frames[i + 1] or frames[1];

		model:PivotTo(cf0:Lerp(cf1, progress % 1));
		
		progress = (progress + fps * dt) % #frames;
	end)
end

I use BindToRenderStep here, though you can reproduce an identical mechanism by subscribing to RenderStepped. Keep in mind that BindToRenderStep does not yield when called and neither does the function by association. To terminate the replay, you must use UnbindFromRenderStep.

As a test, I passed a single BasePart for the model, a track of frames (in our case, just a table of CFrames for simplicity) with each frame representing a corner of an arbitrary square, and different FPS values to yield different interpolation speeds:

replay(brick, {
	CFrame.new(-2.5, 0, -10);
	CFrame.new(2.5, 0, -10);
	CFrame.new(2.5, 0, -5);
	CFrame.new(-2.5, 0, -5);
}, fps);

30 fps:

4 fps:

1 fps:

the issue with your approach is that I optimized recording by only creating a new recording if a change was made.

For example rather than saving the same position for a object that doesn’t move, I only move it when changes are made.

In fact if you want to check it out I am going to go ahead and post the current version:

In that case, you can modify the replay behavior such that it handles pauses in your animation track table. For instance, you can include numbers between keys in the table to denote pause times:

local frames = {
    CFrame.new(0, 0, 0);
    1.25;
    CFrame.new(10, 0, 0);
};

In this manner, interpolation should only occur between frames and after pauses. This is just a proposition though, and adding such a layer of complexity may require extensive critical thinking and trial/error. If you would like to stick with the original function or continue to use welds, feel free to do so.

My system already follows this structure. the only issue is tweening models to their correct CFrames because my tweenModel() doesn’t currently work.

image

As I mentioned I gave you a link to the current version if you want to test it out. Although your system could be considered more optimized, mine is easier to code but is still the same thing.

image

As you can see the endTime is -1 which means it lasts the entire duration, and the startTime is 0.

The reason I stored position and orientation differently is because I have to check if the position is different within the recording process and there are a lot of extra information to check for with CFrames opposed to merely position and orientation.

That’s a very intricate system! Since you already possess durations, positions, and orientations, you can easily employ the manual PivotTo interpolation seen in both of my propositions. Since PivotTo doesn’t require welds, it should (as proven) work seamlessly in viewport frames.

I would say my coding ability is that I am really good at making complex systems but I am limited in my knowledge for specific things.

For example, if I find a method that already works, I will always use that and not learn any other way of doing it. This is because I find it a bit tedious to do so when I already have a system that works.

However, I am now encountering a situation where the method I relied on for so long (tweeting w/ welds) doesn’t work. I don’t have a lot of experience with renderstepped (as I use while) nor do I have experience in Lerping.

With that in mind, here is my current system for replaying files:

while isRecording do
	local data = recordingsPlaying[recordingId]
        -- check if file has not ended
	if data and data["time"] < data["endTime"] and data["time"] >= 0 then

		-- retrieve the data folder inside the viewportframe to store stuff
		local dataFolder = viewPortFrame.WorldModel:FindFirstChild("data")

		for partName,partData in pairs(data["record"]) do
			-- Check if a part exists
			local isPlaced = dataFolder:FindFirstChild(partName)			

			-- Find closest recording
			local closestRecoding, closestIndex = findClosestRecording(partData, data["time"])

			-- Check if its within its end and start
			local inBounds = checkInBounds(data["time"], closestRecoding["startTime"], closestRecoding["endTime"])

			-- if in bounds
			if inBounds then
				-- if not placed then
				if not isPlaced then
					--place
					placeObject(partName, closestRecoding["position"], closestRecoding["orientation"], dataFolder)

					--elseif not correct position then
				elseif checkPosition(isPlaced, closestRecoding["position"], closestRecoding["orientation"]) == false and isPlaced:GetAttribute("animating") == nil then
					--animate()
					task.spawn(animate, isPlaced, closestRecoding["position"], closestRecoding["orientation"])
				end
			-- if not in bounds
			else

				local nextRecording = findNextRecording(closestIndex, partData)
				-- I check if the recording distance is .1 so that it allows files next to each other to animate and not immediately delete.
				if nextRecording and  not nextRecording["startTime"] - currentRecording["startTime"] >= .1 then
					-- destroy()
					isPlaced:Destroy()
				elseif nextRecording == nil and isPlaced then
					isPlaced:Destroy()
				end
			end
		end


		isRecording = not data["canceled"]
	else
		isRecording = false
	end
	local timeToWait = .1
	task.wait(timeToWait)
	data["time"] += timeToWait
end

so as you can see its a bit difficult to incorporate your current logic as I have specific systems to manage deletion, animation, placing, etc.

Another thing that is a bit difficult is that I have to stay in sync with the record file. Although I do admit my current system doesn’t do that, I can easily do this by changing the timeToWait to the record FPS.

to clarify also the section in my code that is “animate” is this function

function animate(object, position, orientation)
	local finalCf = CFrame.new(Vector3.new(position["X"], position["Y"], position["Z"])) * CFrame.Angles(math.rad(orientation["X"]), math.rad(orientation["Y"]), math.rad(orientation["Z"]))
	if object:IsA("Model") then
		tweenModel(object, finalCf)
	else
		local tween = game:GetService("TweenService"):Create(object, TweenInfo.new(.1, Enum.EasingStyle.Linear), {["CFrame"] = finalCf})
		tween:Play()
		tween.Completed:Wait()
		object:SetAttribute("animating", nil)
	end

end

in which this function checks if its a model, then plays this function:

local function tweenModel(model, endCFrame)
	local primaryPart = model.PrimaryPart

	-- If there's no primary part, create a placeholder part
	if not primaryPart then
		primaryPart = Instance.new("Part")
		primaryPart.Name = "ModelCenter"
		primaryPart.Anchored = true
		primaryPart.Size = Vector3.new()
		primaryPart.CFrame = model:GetModelCFrame()
		primaryPart.Parent = model
	end

	local startCFrame = primaryPart.CFrame
	local info = TweenInfo.new(.1, Enum.EasingStyle.Linear, Enum.EasingDirection.InOut)
	local tween = tweenService:Create(primaryPart, info, {CFrame = endCFrame})

	tween:Play()
end

however my current tweenModel doesn’t seem to be working as intended. And from reviewing the function you offered, it seems that it wants me to give it a dictionary of frames. However, as mentioned, my system is a bit more different that yours and I don’t think it would work the same.

here is what it looks like when I try to do your function

oh wait I am so dumb the reason it wasn’t working was because I had to set the attribute “animating” to false.

Here is the final script:


local function tweenModel(model: PVInstance, frames: { CFrame },  fps: number)
	
	local primaryPart = model.PrimaryPart

	-- If there's no primary part, create a placeholder part
	if not primaryPart then
		primaryPart = Instance.new("Part")
		primaryPart.Name = "ModelCenter"
		primaryPart.Anchored = true
		primaryPart.Size = Vector3.new()
		primaryPart.CFrame = model:GetModelCFrame()
		primaryPart.Parent = model
	end

	local startCFrame = primaryPart.CFrame
	local info = TweenInfo.new(1/fps, Enum.EasingStyle.Linear, Enum.EasingDirection.InOut)
	local tween = tweenService:Create(primaryPart, info, {CFrame = frames})

	tween:Play()
	
	model:SetAttribute("animating", nil)
end

dude you say that I pass a CFrame in your function

However when I do this I get error on this line:
image

Heres your code:

local function tweenModel(model: PVInstance, frames: { CFrame },  fps: number)
	local progress = 0;

	runService:BindToRenderStep('replay', 0, function(dt)
		local i = math.floor(progress) + 1;
		local cf0 = frames[i];
		local cf1 = frames[i + 1] or frames[1];

		model:PivotTo(cf0:Lerp(cf1, progress % 1));

		progress = (progress + fps * dt) % #frames;
	end)
end

Hey I ended up reprogramming the system:

local runService = game:GetService("RunService")

local activeTweens = {}

local function tweenModel(model, targetCF, fps)
	if activeTweens[model] then
		activeTweens[model]:Disconnect()
		activeTweens[model] = nil
	end

	local initialCF = model:GetPivot()
	local progress = 0

	local connection
	connection = runService.RenderStepped:Connect(function(dt)
		progress = math.min(1, progress + dt * fps)

		local currentCF = initialCF:Lerp(targetCF, progress)
		model:PivotTo(currentCF)

		if progress >= 1 then
			connection:Disconnect()
			activeTweens[model] = nil
		end
	end)

	activeTweens[model] = connection
end

it now works. thanks for the head start.

{ CFrame } denotes a table of CFrame values, not a single CFrame. Attempting to index a CFrame with a number gives an error similar to what you encountered.

The second approach I gave you was intended to receive a sequence of CFrames (a primitive animation track) to interpolate between rapidly. Hence the arguments I used to test: