[Release] Custom character controller

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:

38 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?

I didn’t know you had to access it with a script…I only though you had to put the scripts in the model and put the player module script under starter
player scripts.

You could ask on #help-and-feedback:scripting-support for this though the module has its’ own documentation.

The problem is that I already have and haven’t gotten a reply

This looks amazing! I’d like to experiment with this. Great job!

This is so cool! I dont get how people can script such amazing stuff like this!