Problem with your current approach
Currently, you are using the position of the root but you ignore the direction from which the character arrived to that position. When moving on a sphere, the character’s path along the surface is important for the end result that I believe you want. When the the player is not applying additional rotation with mouse movement and thus the camera rotation only depends on character movement, I believe that you’d like the camera to rotate in the exact same way as the tangent plane of the surface rotates.
Here’s an explanation of why the current position is not enough to define the end result I believe you want. Let’s assume that we have a sphere whose center point is origin (0, 0, 0) and radius is 5 and an object on the sphere surface. The orientation of the object is defined by three vectors: upvector that is parallel to the surface normal, and rightvector and lookvector that are parallel to the surface tangent plane.
Let’s assume that the object is initially at (0, 5, 0) i.e. the top of the sphere. Let’s assume that initially rightvector is (1, 0, 0) and lookvector is (0, 0, -1) and that when the object moves, it rotates in the same way as the surface tangent plane. This way, upvector will always be parallel to the surface normal. I’ll describe two ways for the object to reach point (5, 0, 0).
- Option 1: Move to point (0, 0, 5) along a cross section circle that is parallel to yz-plane and then move to the destination (5, 0, 0) along a cross section circle that is parallel to the xz-plane: When the object reaches point (0, 0, 5), rightvector is still (1, 0, 0) but lookvector is (0, 1, 0). When it reaches the destination, rightvector has become (0, 0, -1) and this time the lookvector remains as (0, 1, 0).
- Option 2: Moving to the destination (5, 0, 0) along a cross section circle that is parallel to the xy plane: When the object reaches the destination, rightvector is (0, -1, 0) and lookvector remains as (0, 0, -1).
Despite the object ending up in the same position, its orientation in that position is different. The upvector is of course the same regardless of the path taken but the rightvector and lookvector in the end position depend on the path taken. When the user is moving their character but not their mouse, I believe your camera should behave in the same way as the object I wrote about above.
Implementation suggestions
The way I’d suggest you to implement the camera rotation is that every frame, you first update the camera CFrame based on character movement. Then apply the angles defined by mouse movement during the frame.
Here’s some example code that you can use to test whether my assumption about the desired behavior is correct. Just put the code in a client script and maybe also disable Players.CharacterAutoLoads. Perhaps you can adapt the parts related to camera movement and rotation (mainly in onRenderStepped) to your code if this is the kind of camera behavior you want. I could be wrong about the result you are looking for so correct me if that’s the case.
--!strict
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local MOVEMENT_SPEED: number = 16
local DEGREES_PER_PIXEL: number = .25
local CHAR_DIST_FROM_SURFACE: number = 4
local CAMERA_DIST_FROM_CHAR: number = 10
local SPHERE_POS: Vector3 = Vector3.new(0, 100, 0)
local SPHERE_DIAMETER: number = 100
local CHAR_SIZE: Vector3 = Vector3.new(1, 1, 1)
local sphere: Part
local character: Part
local mousePrevPos: Vector2 = UserInputService:GetMouseLocation()
local charPrevPos: Vector3
local function move(deltaTime: number): ()
local movementVec2D: Vector2 = Vector2.zero
if UserInputService:IsKeyDown(Enum.KeyCode.W) then
movementVec2D += Vector2.new(-1, 0)
end
if UserInputService:IsKeyDown(Enum.KeyCode.S) then
movementVec2D += Vector2.new(1, 0)
end
if UserInputService:IsKeyDown(Enum.KeyCode.A) then
movementVec2D += Vector2.new(0, -1)
end
if UserInputService:IsKeyDown(Enum.KeyCode.D) then
movementVec2D += Vector2.new(0, 1)
end
if movementVec2D == Vector2.zero then
return
end
movementVec2D = movementVec2D.Unit
local oldCf: CFrame = character.CFrame
local oldSurfaceNormal: Vector3 = (oldCf.Position - sphere.Position).Unit
local cameraCf: CFrame = workspace.CurrentCamera.CFrame
local backVec: Vector3 = cameraCf.RightVector:Cross(oldSurfaceNormal)
local initialMovementDir: Vector3 =
movementVec2D.X * backVec
+ movementVec2D.Y * cameraCf.RightVector
local rotationAxis: Vector3 = oldSurfaceNormal:Cross(initialMovementDir).Unit
local angle: number = deltaTime * MOVEMENT_SPEED / (.5 * sphere.Size.Y + CHAR_DIST_FROM_SURFACE)
local axisAngleCf: CFrame = CFrame.fromAxisAngle(rotationAxis, angle)
character.Position = axisAngleCf * (character.Position - sphere.Position) + sphere.Position
character.CFrame = CFrame.lookAlong(character.Position, axisAngleCf * initialMovementDir, axisAngleCf * oldSurfaceNormal)
end
local function onRenderStepped(deltaTime: number): ()
move(deltaTime)
local camera: Camera = workspace.CurrentCamera
local prevSurfaceNormal: Vector3 = (charPrevPos - sphere.Position)
local currentSurfaceNormal: Vector3 = (character.Position - sphere.Position)
local rotationCf: CFrame = CFrame.fromRotationBetweenVectors(prevSurfaceNormal, currentSurfaceNormal)
camera.CFrame = rotationCf * (camera.CFrame - sphere.Position) + sphere.Position
if UserInputService:IsMouseButtonPressed(Enum.UserInputType.MouseButton2) then
local mousePos: Vector2 = UserInputService:GetMouseLocation()
local mouseDelta: Vector2 = mousePos - mousePrevPos
local extraHorizontalRotation: number = mouseDelta.X * math.rad(DEGREES_PER_PIXEL)
local extraVerticalRotation: number = mouseDelta.Y * math.rad(DEGREES_PER_PIXEL)
local horRotationCf: CFrame = CFrame.fromAxisAngle(currentSurfaceNormal, -extraHorizontalRotation)
camera.CFrame -= character.Position
camera.CFrame = horRotationCf * camera.CFrame
local vertRotationCf: CFrame = CFrame.fromAxisAngle(camera.CFrame.RightVector, -extraVerticalRotation)
camera.CFrame = vertRotationCf * camera.CFrame
camera.CFrame += character.Position
end
mousePrevPos = UserInputService:GetMouseLocation()
charPrevPos = character.Position
end
local function createSphere(): ()
sphere = Instance.new("Part")
sphere.Name = "CameraTestSphere"
sphere.Material = Enum.Material.Rock
sphere.BottomSurface, sphere.TopSurface = Enum.SurfaceType.Smooth, Enum.SurfaceType.Smooth
sphere.Shape = Enum.PartType.Ball
sphere.Size = SPHERE_DIAMETER * Vector3.one
sphere.Position = SPHERE_POS
sphere.Anchored = true
sphere.Parent = workspace
return sphere
end
local function createCharacter(): ()
character = Instance.new("Part")
character.Name = "CharacterPart"
--character.BottomSurface, character.TopSurface = Enum.SurfaceType.Smooth, Enum.SurfaceType.Smooth
character.FrontSurface = Enum.SurfaceType.Hinge
character.Size = CHAR_SIZE
character.Position = sphere.Position + (.5 * sphere.Size.Y + CHAR_DIST_FROM_SURFACE) * Vector3.yAxis
character.Anchored = true
character.Parent = workspace
end
local function startTest(): ()
createSphere()
createCharacter()
workspace.CurrentCamera.CameraType = Enum.CameraType.Scriptable
workspace.CurrentCamera.CFrame = character.CFrame + CAMERA_DIST_FROM_CHAR * Vector3.zAxis
charPrevPos = character.Position
RunService.RenderStepped:Connect(onRenderStepped)
end
startTest()
Edit:
I’m not sure how clear my written explanation about the path dependency is so here’s also a video in case it helps someone who’s reading this in the future. In the video, red is right vector, green is up vector and blue is back vector.