[Release] Custom character controller

Your first statement is right, no correction needed!

There is no fake camera being made, what’s actually happening is that EgoMoose made sure to modify the camera as late as possible into the render step, on Enum.RenderPriority.Last.Value + 1, and he connected this function from the Main local script in StarterCharacterScripts.
Because of this, you are probably seeing the camera in its default state before its CFrame was modified by the Controller.
You can try printing the camera’s CFrame every frame by binding a print function to the Enum.RenderPriority.Last.Value + 2 priority.


The way the camera’s CFrame is modified is through usage of this equation:

And EgoMoose uses this in his Controller module’s Update method too:

diff = hitCF * fakeCF:Inverse() * extra

It’s just a bit more optimized in my humble opinion. He goes on to apply diff to create his new camera CFrame, but that is just a lesson for normal camera systems, saved for another day maybe.

What this equation does is it takes the orientation of the current part the character is standing on, and multiplies it by the inverse orientation of the fake part that the fake character is standing on.

An interesting property of CFrames is that multiplying the CFrame of a part by the inverse CFrame of a different part will give you a relative relationship between the two parts, just like how subtracting a vector3 from another will give you a relative relationship between the two, and pretty much subtracting anything by another thing will give you a relative relationship between the two. This property of CFrames was especially taken advantage of to give you the camera system you see there.
This diff variable is literally the only difference between a normal camera system and the custom camera system. If you comment it out, then poof, it’s a normal camera system, simple as that.
You might have noticed the… extra variable at the end of EgoMoose’s equation, this is an offset for fixing that weird camera bug (from the end of post #70) where the camera spins 180 degrees when you switch between X-pointed slopes and Z-pointed slopes, and the same for running around on spheres. You can probably see what I mean if you download the old playgroundCamera file from the post. This was also used to make transitions from and to rParts seamless. I am still half-surprised about how I figured it out, but it is in post #71.

7 Likes

That’s very sneaky! This was a bit of an aha moment for me because I never understood the importance of render priority until now, so thanks!

Also, is there any use for the Cast module? I don’t see it used anywhere. In fact, I don’t see any raycasts anywhere, presumably because SetNormal() requires that a part be passed?

3 Likes

Cast module is mostly independent from the controller. Its attached purely due to laziness of me not wanting to redefine stuff. It’s not used anywhere in the controller itself, but is meant to provide access to a few different types of raycast functions.

As a side note, the camera is modified but only slightly. The default camera uses the previous camera cframe where as the modified version goes purely off of cumulative inputs. Furthermore, the modified camera has collision/transparency (popper cam etc) removed bc it’s applied after the cframe is adjusted to match character rotation.

3 Likes

Use for the Cast module can be found in the Main local script in StarterCharacterScripts, in line 41 to be exact. This is also the place where the Controller module itself is used.

Keep note that the Main script is where you can make the most modifications (because it is the script that actually uses the controller module), so if you wanted to incorporate the character controller into your game, this is where you would do it.

3 Likes

I’m running into an issue with animations on R15. While running, sometimes the running animation will just stop but the character continues to respond to controls like a normal robloxian would. If another animation is started, such as the falling or jumping animation, the character unfreezes.

I think this has something to do with always being on a fake world? Not entirely sure.

So I’m aware that you’ve already got this figured out from twitter, but outside of the devforum I’ve received interest and questions in regards to how you might set an arbitrary and relative up for the camera. So in the spirit of those questions I make this post:

First off, the method of approach here is completely different from the character controller above. The reasoning behind this is two-fold. For one, in this post I’m not covering how we match the rotation of a horizontally spinning part. If you’re interested in that I suggest you check out this post. Secondly, the controller’s method of approach is more complicated than it needs to be because it must match horizontal spinning. As a result, we can opt for a simpler to understand approach.

Laying the ground work

Right, so how do we do this? Well to start off the standard camera controller as is not in a state that is susceptible to modding for arbitrary rotation. We’ll have to make some adjustments to the controller that will make it play nice once we actually start trying to rotate it. So grab a copy of the vanilla PlayerModule and get ready to make some changes.

There are few problems here so we’ll lay them out as we go.

To start let’s look at the BaseCamera:CalculateNewLookCFrame(suppliedLookVector) method:

local MIN_Y = math.rad(-80)
local MAX_Y = math.rad(80)

function BaseCamera:CalculateNewLookCFrame(suppliedLookVector)
	local currLookVector = suppliedLookVector or self:GetCameraLookVector()
	local currPitchAngle = math.asin(currLookVector.y)
	-- note: Util.Clamp does the same thing as math.clamp but the argument's order are switched around
	local yTheta = Util.Clamp(-MAX_Y + currPitchAngle, -MIN_Y + currPitchAngle, self.rotateInput.y)
	local constrainedRotateInput = Vector2.new(self.rotateInput.x, yTheta)
	local startCFrame = CFrame.new(ZERO_VECTOR3, currLookVector)
	local newLookCFrame = CFrame.Angles(0, -constrainedRotateInput.x, 0) * startCFrame * CFrame.Angles(-constrainedRotateInput.y,0,0)
	return newLookCFrame
end

The most important thing to note from this method is that we’re calculating (and thus clamping) our pitch angle based on a world space vector. Since in in world space the up vector will always be Vector3.new(0, 1, 0) this obviously ain’t gunna work for us :sunglasses:.

To fix this we’ll adjust our BaseCamera:CalculateNewLookCFrame(suppliedLookVector) method so that it no longer calculates our pitch angle based on a look vector. Instead we’ll sum the input deltas so that we know the angles needed to build our camera’s input rotation from scratch at any given moment.

BaseCamera.xInput = 0
BaseCamera.yInput = 0

function BaseCamera:CalculateNewLookCFrame(suppliedLookVector)
	self.xInput = self.xInput - self.rotateInput.x
	self.yInput = math.clamp(self.yInput + self.rotateInput.y, MIN_Y, MAX_Y)
	-- apply yaw then pitch hence fromEulerAnglesYXZ
	local lookVector = CFrame.fromEulerAnglesYXZ(self.yInput, self.xInput, 0) * Vector3.new(0, 0, 1)
	return CFrame.new(Vector3.new(), lookVector)
end

Now if you were so inclined you could take a shot at trying to rotate the camera now. We’ll do this in the camera module’s CameraModule:Update(dt) method. There’s a few steps going on here:

  1. Calculate the camera’s CFrame based on inputs alone.
  2. Apply occlusion (i.e. make sure the camera’s view isn’t blocked by a part).
  3. Set the camera’s CFrame.
  4. Set local transparencies (i.e. make the character locally invisible if in first person).

We’ll be wanting to apply our rotation between steps 1 and 2. We want to apply after step 1 because that’s where all the “control” happens, and we want to apply before step 2 because we still want accurate occlusion with our rotated camera.

-- setting Vector3.new(1, 0, 0) as the relative "up"
CameraModule.RotatedCFrame = CFrame.new(Vector3.new(), Vector3.new(1, 0, 0)) * CFrame.Angles(-math.pi/2, 0, 0)

function CameraModule:Update(dt)
	if self.activeCameraController then
		local newCameraCFrame, newCameraFocus = self.activeCameraController:Update(dt)
		self.activeCameraController:ApplyVRTransform()

		-- apply the rotation
		local offset = newCameraFocus:Inverse() * newCameraCFrame
		newCameraCFrame = newCameraFocus * self.RotationCFrame * offset
		
		if self.activeOcclusionModule then
			newCameraCFrame, newCameraFocus = self.activeOcclusionModule:Update(dt, newCameraCFrame, newCameraFocus)
		end
		
		-- Here is where the new CFrame and Focus are set for this render frame
		game.Workspace.CurrentCamera.CFrame = newCameraCFrame
		game.Workspace.CurrentCamera.Focus = newCameraFocus
		
		-- Update to character local transparency as needed based on camera-to-subject distance
		if self.activeTransparencyController then
			self.activeTransparencyController:Update()
		end
	end
end

Sure enough, if you try this out you get a result that I can only describe as “wack!”.

Turns out the thing causing this problem is the popper cam occlusion module. Looking at the Poppercam:Update(renderDt, desiredCameraCFrame, desiredCameraFocus, cameraController) we’ll note our rotated focus being calculated using the CFrame.new(pos, lookAt) constructor. This rotatedFocus CFrame is then later used to recalculate the camera CFrame.

function Poppercam:Update(renderDt, desiredCameraCFrame, desiredCameraFocus, cameraController)
	local rotatedFocus = CFrame.new(desiredCameraFocus.p, desiredCameraCFrame.p)*CFrame.new(
		0, 0, 0,
		-1, 0, 0,
		0, 1, 0,
		0, 0, -1
	)
	local extrapolation = self.focusExtrapolator:Step(renderDt, rotatedFocus)
	local zoom = ZoomController.Update(renderDt, rotatedFocus, extrapolation)
	return rotatedFocus*CFrame.new(0, 0, zoom), desiredCameraFocus
end

Unfortunately the CFrame.new(pos, lookAt) constructor always assumes a world space Vector3.new(0, 1, 0) as the up vector and as a result overwrites that rotation we pre-multiplied in earlier.

Luckily enough it’s a simple enough fix, we’ll just carry over our camera’s rotation aspect to the rotated focus.

function Poppercam:Update(renderDt, desiredCameraCFrame, desiredCameraFocus, cameraController)
	local rotatedFocus = desiredCameraFocus * (desiredCameraCFrame - desiredCameraCFrame.p)
	local extrapolation = self.focusExtrapolator:Step(renderDt, rotatedFocus)
	local zoom = ZoomController.Update(renderDt, rotatedFocus, extrapolation)
	return rotatedFocus*CFrame.new(0, 0, zoom), desiredCameraFocus
end

Dope, we got a camera that we can set an arbitrary up vector for :partying_face:.

Adding some polish

So I suppose that answers most of the elusive bits, but we can take it a bit further. I’d imagine most use cases of this “arbitrary up” behaviour would revolve around constantly adjusting what the up vector is. This is possible to do in the current form by using a public method or property to update the CFrame we pre-multiply by.

CameraModule.UpVector = Vector3.new(0, 1, 0)
CameraModule.RotationCFrame = CFrame.new()

function CameraModule:SetUpVector(newUpVector)
	self.UpVector = newUpVector
	self.RotationCFrame = CFrame.new(Vector3.new(), newUpVector) * CFrame.Angles(-math.pi/2, 0, 0)
end

This works, but the transition is a little jarring. We can fix this with some simple lerping, right?

CameraModule.UpVector = Vector3.new(0, 1, 0)
CameraModule.RotationCFrame = CFrame.new()
CameraModule.TransitionRate = 0.15

-- In the video below I call this method in a local script and set the up vector to the surface normal I click on 
function CameraModule:SetUpVector(newUpVector)
	self.UpVector = newUpVector
end

function CameraModule:Update(dt)
	if self.activeCameraController then
		local newCameraCFrame, newCameraFocus = self.activeCameraController:Update(dt)
		self.activeCameraController:ApplyVRTransform()

		local oldCF = self.RotationCFrame
		local newCF = CFrame.new(Vector3.new(), self.UpVector) * CFrame.Angles(-math.pi/2, 0, 0)
		self.RotationCFrame = oldCF:Lerp(newCF, self.TransitionRate)
		
		local offset = newCameraFocus:Inverse() * newCameraCFrame
		newCameraCFrame = newCameraFocus * self.RotationCFrame * offset

		if self.activeOcclusionModule then
			newCameraCFrame, newCameraFocus = self.activeOcclusionModule:Update(dt, newCameraCFrame, newCameraFocus)
		end
		
		-- Here is where the new CFrame and Focus are set for this render frame
		game.Workspace.CurrentCamera.CFrame = newCameraCFrame
		game.Workspace.CurrentCamera.Focus = newCameraFocus
		
		-- Update to character local transparency as needed based on camera-to-subject distance
		if self.activeTransparencyController then
			self.activeTransparencyController:Update()
		end
	end
end

A bit less jarring maybe, but disorienting, absolutely. Why do I get spun to a completely different direction when I change up vectors? That’s really confusing and hard to control as a user.

The reason we’re getting this result is because we’re not calculating the transition cframe that when pre-multiplied by the old up vector, gets us the new up vector. To do that we’ll need to make use of a Rodrigues’ rotation. Luckily for us we don’t need to know the math behind it because Roblox has the built in constructor CFrame.fromAxisAngle. If you interested in the math however, check here.

Our goal here is to find the closest rotation between two arbitrary up vectors, an old one and a new one. There are three cases to deal with.

  1. When the old vector and the new vector are basically the same we would pre multiply by CFrame.new() since we want to make no change.

  2. When the old vector and the new vector are different, but not complete opposites i.e. old ~= -new. We use the cross product to get the axis of rotation and the dot product to get the angle of rotation. We can then plug these into the CFrame.fromAxisAngle constructor to get the transition CFrame.

  3. If the old vector and the new vector are complete opposites i.e. old == -new then we choose the axis of rotation as the camera’s current pitch axis.

In code:

local function getTranstionBetween(v1, v2, pitchAxis)
	local dot = v1:Dot(v2)
	if (dot > 0.99999) then
		return CFrame.new()
	elseif (dot < -0.99999) then
		return CFrame.fromAxisAngle(pitchAxis, math.pi)
	end
	return CFrame.fromAxisAngle(v1:Cross(v2), math.acos(dot))
end

We can now use this CFrame as delta to be pre-multiplied to the RotatedCFrame property. Of course we still don’t want jarring transitions so we can lerp this delta by the TransitionRate property from an un-rotated state.

All said and done my final code is the following:

CameraModule.UpVector = Vector3.new(0, 1, 0)
CameraModule.RotationCFrame = CFrame.new()
CameraModule.TransitionRate = 0.15

local function getTranstionBetween(v1, v2, pitchAxis)
	local dot = v1:Dot(v2)
	if (dot > 0.99999) then
		return CFrame.new()
	elseif (dot < -0.99999) then
		return CFrame.fromAxisAngle(pitchAxis, math.pi)
	end
	return CFrame.fromAxisAngle(v1:Cross(v2), math.acos(dot))
end

function CameraModule:GetUpVector(oldUpVector)
	return oldUpVector
end

function CameraModule:CalculateRotationCFrame()
	local oldUpVector = self.UpVector
	local newUpVector = self:GetUpVector(oldUpVector)
	
	local transitionCF = getTranstionBetween(oldUpVector, newUpVector, game.Workspace.CurrentCamera.CFrame.RightVector)
	local adjustmentCF = CFrame.new():Lerp(transitionCF, self.TransitionRate)
	
	self.UpVector = adjustmentCF * oldUpVector
	self.RotationCFrame = adjustmentCF * self.RotationCFrame
end

function CameraModule:Update(dt)
	if self.activeCameraController then
		local newCameraCFrame, newCameraFocus = self.activeCameraController:Update(dt)
		self.activeCameraController:ApplyVRTransform()

		self:CalculateRotationCFrame()
		local offset = newCameraFocus:Inverse() * newCameraCFrame
		newCameraCFrame = newCameraFocus * self.RotationCFrame * offset
		
		if self.activeOcclusionModule then
			newCameraCFrame, newCameraFocus = self.activeOcclusionModule:Update(dt, newCameraCFrame, newCameraFocus)
		end
		
		-- Here is where the new CFrame and Focus are set for this render frame
		game.Workspace.CurrentCamera.CFrame = newCameraCFrame
		game.Workspace.CurrentCamera.Focus = newCameraFocus
		
		-- Update to character local transparency as needed based on camera-to-subject distance
		if self.activeTransparencyController then
			self.activeTransparencyController:Update()
		end
	end
end

There we go, a solid working camera with an arbitrary up vector! :partying_face:

CustomCameraUpVector.rbxl (144.1 KB)

Good luck and enjoy! :grin:

37 Likes

Just a question, is there a way i can make this work with the lineforce of a planet?
That would be handy, because then not all players need the same gravity.

Currently what happens is pretty weird: https://gyazo.com/6640f6af3e2e721e63be1869ec40a561
Without the character controller this happens:
https://gyazo.com/25bc02174ee838c5e72e8752e001130d

So is there a way i can make the same happen with the character controller?

I’m not entirely sure why that’s happening, but if you are interested in a more physics based controller you can take a look at this.

The idea for applying this to a character is mostly the same. Simply weld a small transparent ball to the character to handle collision and apply forces as shown in the game. The only thing that makes this a pain is you need to handle animation states on your own.

3 Likes

In your demo place there’s a Local Script in StarterCharacterScripts called Main. I removed the checkpoint system and now the script looks like this:

local CHARACTERS = game.Workspace.CHARACTERS
while game.Players.LocalPlayer.Character.Parent ~= CHARACTERS do wait() end

local controller = require(game.ReplicatedStorage.Controller).new(game.Players.LocalPlayer)

-- Custom controller

controller.Ignore = CHARACTERS
controller.Cast.Ignore = CHARACTERS
controller.CustomCameraEnabled = true
controller.CustomCameraSpinWithrParts = false

controller:SetEnabled(true)

game:GetService("RunService"):BindToRenderStep("After Camera", Enum.RenderPriority.Last.Value + 1, function(dt)
    local hit, pos, normal = controller.Cast:Line(4)
    

    if (not hit or hit.Name == "IgnoredPart") then
	    hit = game.Workspace.Terrain
	    normal = controller.Last.Hit.CFrame:VectorToWorldSpace(controller.Last.ObjectNormal)
    end

    -- can be used as a defacto debounce
    if (hit.CanCollide and tick() - controller.NormalUpdateTick > 0.1) then
	    controller:SetNormal(normal, hit)
    end
    controller:Update(dt)
end)

What is deltaTime and why is there an error in the output?

RunService:fireRenderStepEarlyFunctions unexpected error while invoking callback: ReplicatedStorage.Controller:415: attempt to index field 'Hit' (a nil value)

When testing the Gravity Swords place you have on roblox, I cant seem to get my character to move or anything. I wanted to see it working in action. When opening in studio I can get the character to move but it isnt moving as intended either. Is the place file up to date or is it temporarily non functioning?

The ladder file doesnt seem to be working as intended either?

1 Like

It seems like the new sound update from here broke the character controller, since it copies sounds over to the character manually. Thanks for pointing this out!

I ported over EgoMoose’s updated character controller, it should work like a charm now.

1 Like

I was curious if that was causing issues. Ive noticed its caused a few issues in some other things Ive been researching lately.

2 Likes

Yes, for the record i didn’t bother actually fixing this. I just disabled the sound script so that there aren’t any errors.

5 Likes

I figured out a fix to your issue by the way, it seems like controller.Last.Hit doesn’t exist for at least a moment, which leads to that error.
You can replace controller.Last.Hit.CFrame with controller.Last.HitCF, and it should work fine.

2 Likes

Still don’t works for me😐 Now the camera isn’t turning anymore and it gives me that error in the output.

OPImg

Thank u for your help✌

2 Likes

This is a pretty cool!
I’m using it in my game for a fast travel system

6 Likes

Just missing the ability to use shift lock to rotate the character and the ability to not hold space bar to keep jumping but other than that its great!

1 Like

Thought i’d release this for anyone still interested in EgoMoose’s Custom Character Controller:

https://www.roblox.com/games/4330418926/S-E-T-Sonic-Engine-Testing?refPageId=f1210d1e-4074-439e-9106-6c9cca000f4c

5 Likes

This doesn’t work for me :woman_shrugging: I think other people are encountering this problem too

It works good, have you made the script which it’s supposed to access the module with?