CutsceneService - Smooth cutscenes using Bézier curves

Why not use splines that go through the points? just curious

Perfect, this seems to be fixed. Thank you for the quick response!

1 Like

The reason I chose Bézier curves is because they are optimal for camera paths: They go through the first and last point and generate a smooth curve between them. Furthermore, they are very easy to compute.

Interpolating curves (curves that pass through all points) can be smooth as well, but not necessarily as much as Bézier curves. However an advantage of interpolating curves is that you have more control over the path.

Additionally, Bézier curves are very easy to implement for Roblox’s CFrames due to the built-in Lerp function. This can be complicated for other curves.

The fact that Bézier curves are the default doesn’t mean you can’t use your own. You can simply replace the getCF function in the source code.

Another thing I’ve come across with looping is that the cutscene will jump from the end point to the start point. Is there a special function for preventing this, so it will smoothly go between the points? I’m currently just putting the start point at the end.

Nope, unfortunately there is not a special function for this. You have to make the first and last point the same.
If you want, you can add this function in the source code which will solve your problem:

function cutscene:PlayLoop()
	if module.Playing then error("A cutscene is already playing") end
	
	local points = self.Points
	if points[1] ~= points[#points] then
		table.insert(points, points[1])
	end
	
	self:Play()
	self.Next = self
end

Hi! Im trying to cancel a looping queue to play another cutscene but im met with this error, how would I solve it?

ReplicatedStorage.CutsceneService:519: invalid argument #1 to ‘next’ (table expected, got nil)

Here is my code:

local CutsceneService = require(game.ReplicatedStorage.CutsceneService)
		local BattleInfo = Arguments[1]
		
		local IdleScene1 = CutsceneService:Create(BattleInfo.Parent:FindFirstChild("IdleScene1"), 5, "InOutQuart")
		local IdleScene2 = CutsceneService:Create(BattleInfo.Parent:FindFirstChild("IdleScene2"), 5, "InOutQuart")
		local IdleScene3 = CutsceneService:Create(BattleInfo.Parent:FindFirstChild("IdleScene3"), 5, "InOutQuart")
		local IdleScene4 = CutsceneService:Create(BattleInfo.Parent:FindFirstChild("IdleScene4"), 5, "InOutQuart")
		local IdleScene5 = CutsceneService:Create(BattleInfo.Parent:FindFirstChild("IdleScene5"), 5, "InOutQuart")
		
		local ActionScene1 = CutsceneService:Create(BattleInfo.Parent:FindFirstChild("ActionScene1"), 1, "InOutQuart")
		
		
		local IdleQueue = CutsceneService:CreateQueue(IdleScene1, IdleScene2, IdleScene3, IdleScene4, IdleScene5)
		
		IdleQueue:Play()
		IdleScene5.Next = IdleScene1
		
		State:GetChangedSignal("State"):Connect(function(NewValue)
			if NewValue == "Swapping" or NewValue == "Action" then
				IdleQueue:Cancel()
				ActionScene1:Play()
			elseif NewValue == "Idle" then
				IdleQueue:Play()
				IdleScene5.Next = IdleScene1
			end
		end)

Everything works great and smooth, but if you have Streaming enabled and the parts for the cutscene are out of the radius it just errors, is there any way of fixing it?

The problem is that the client can’t access the CFrames of the parts. The best solution is probably to put them in ReplicatedStorage so they don’t disappear.

2 Likes

I just published version 1.4.3 which fixes your bug. Sorry for the inconvenience!

1 Like

Thank you for the quick response :slight_smile:

1 Like

Is it still possible to do things like shaking the camera while the player is in a cutscene?

Yes, shaking the camera during a cutscene worked perfectly for me. CutsceneService uses Enum.RenderPriority.Camera.Value + 1 as render priority, you probably need to use a higher one for the shake then (like Enum.RenderPriority.Camera.Value + 2).

Do you have an example on how I would implement that?

I used sleitnick’s camera shaker for this:

local CameraShaker = require(game.ReplicatedStorage.CameraShaker)
local explosion = CameraShaker.new(Enum.RenderPriority.Camera.Value + 2, function(cf)
	camera.CFrame *= cf
end)
explosion:Start()

cutscene:Play()
task.wait(1)
explosion:Shake(CameraShaker.Presets.Explosion)
1 Like

Hi,

Great module works well, how would you get the camera to always look at a part while moving?

Thanks

That is not possible with the current API, but you can add your own special function to implement this.

Add this in specialFunctions.Start:

{"FocusOnPart", function(_, part:BasePart)
	assert(part, "FocusOnPart Argument 1 missing or nil")
	--change the algorithm to get a point on the curve
	getCF = function(points, t)
		local copy = {unpack(points)}
		local n = #copy
		for j = 1, n - 1 do
			for k = 1, n - j do
				copy[k] = copy[k]:Lerp(copy[k + 1], t)
			end
		end
		return CFrame.lookAt(copy[1].Position, part.Position)
	end
end}

Add this in specialFunctions.End:

{"FocusOnPart", function()
	--change function back
	getCF = function(points, t)
		local copy = {unpack(points)}
		local n = #copy
		for j = 1, n - 1 do
			for k = 1, n - j do
				copy[k] = copy[k]:Lerp(copy[k + 1], t)
			end
		end
		return copy[1]
	end
end}

Then you can just use it in your script:

local cutscene1 = CutsceneService:Create(
	workspace.Cutscene1, 7, "InOutQuart",
	"FocusOnPart", workspace.SpawnLocation
)

Posting this here because I was asked about it:

While working with Bézier curves, a common problem people encounter is that the camera doesn’t move at constant speed throughout the cutscene.

This is usually solved with arc length parameterization. I recently wrote about this topic when I published a Bézier curves module: Introduction | Bezier

I will show you how to solve it with the module, we have to edit the source code a bit:
Firstly, insert the Bézier module into the game and add this line in the CutsceneService module to require it:

local Bezier = require(game.ReplicatedStorage.Bezier)

Now find this at line ~288 in the Play function:

local duration = self.Duration

and move it under this at line ~305:

assert(#self.PointsCopy > 1, "More than one point is required")

Now we are going to add a special function called “EqualSpeed”:
Add this in specialFunctions.Start as the last entry:

{"EqualSpeed", function(self, speed:number?)
	local vector3Points = {}
	for _, v in self.PointsCopy do
		table.insert(vector3Points, v.Position)
	end
	local curve = Bezier.new(vector3Points)
	curve:UpdateLUT()
	getCF = function(points, t)
		t = curve:ConvertT(t)
		local copy = {unpack(points)}
		local n = #copy
		for j = 1, n - 1 do
			for k = 1, n - j do
				copy[k] = copy[k]:Lerp(copy[k + 1], t)
			end
		end
		return copy[1]
	end
	if speed then
		self.Duration = curve.Length / speed
	end
end}

Add this in specialFunctions.End:

{"EqualSpeed", function(self)
	getCF = function(points, t)
		local copy = {unpack(points)}
		local n = #copy
		for j = 1, n - 1 do
			for k = 1, n - j do
				copy[k] = copy[k]:Lerp(copy[k + 1], t)
			end
		end
		return copy[1]
	end
end}

Now you can have cutscenes that play with constant speed and optionally you can specify this speed.

3 Likes

Not sure what’s the difference between fixed version but i don’t see any problem with it currently.

Normally, the camera doesn’t move at a constant speed with a linear easing style.

These articles explain it:

“DefaultCameraPoint” goes to the wrong CFrame when the player respawns, this normally works fine but If I change teams and spawn somewhere else and call this function it tweens to the old CFrame.