Doesn’t look like the place linked is uncopylocked
Right you are! My mistake; I must have not hit save. It should be open now
This couldn’t have come at a better time because I’m working on modifying the CameraModule to use any direction as its up.
Before I slog through your modified CameraModule, is there any convenient way to use it in my own game without your custom controller? Ex. In my game, I have a custom bee controller in which bees can land on walls, and I want the wall to become the floor.
Actually, the CameraModule
is not modified all too much, EgoMoose tried to isolate as much controller-specific details as possible to the controller
module in ReplicatedStorage
.
Also, what do you mean by using it in your game without the custom controller? Do you mean just turning off the gravity part of the controller so you can just use the camera portion?
That’s exactly what I mean – turning off the gravity while still using the camera portion. I’ll look through the controller
tomorrow and give it a go.
Thought I’d share this as a few weeks back somebody mentioned to me it would be cool if players could more accurately climb non-vertical ladders.
So here’s a very simple demo of that in action:
My point in showing this is to show that this module may be more versatile than you may think so get creative!
Is there a way to make a surface of a part unable to wall walk on?
Yes, you’d have to avoid setting the normal when you walk on that specific surface. A simple if statement that checks the part you are standing on and the object space normal should do the trick.
Correct me if I’m wrong: This controller creates a fake world that rotates around a fake character, whose gravity is always pointed down. Then, the real character’s position/orientation is set to match the fake character’s position/orientation but relative to the real world. I hope that made sense.
Now I’m stuck on how the camera works. Is it also creating a fake camera? I’ve bodged it into my game, and sometimes the camera’s CFrame will be completely different from what I’m actually seeing.
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.
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?
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.
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.
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 .
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:
- Calculate the camera’s CFrame based on inputs alone.
- Apply occlusion (i.e. make sure the camera’s view isn’t blocked by a part).
- Set the camera’s CFrame.
- 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 .
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.
-
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. -
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 theCFrame.fromAxisAngle
constructor to get the transition CFrame. -
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!
CustomCameraUpVector.rbxl (144.1 KB)
Good luck and enjoy!
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.
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?
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.