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!