I managed to accomplish what I needed, primarily with the assistance of this blog post for the surface snapping piece of the puzzle and came up with my own ideas for player movement and camera.
Snapping Player to the Surface of an Object
The basic setup consists of a large sphere (the world) and a smaller sphere (the player) both with sphere colliders attached to them.
The bulk of the work being done was in the following two methods:
private void UpdatePlayerTransform(Vector3 movementDirection)
{
RaycastHit hitInfo;
if (GetRaycastDownAtNewPosition(movementDirection, out hitInfo))
{
Quaternion targetRotation = Quaternion.FromToRotation(Vector3.up, hitInfo.normal);
Quaternion finalRotation = Quaternion.RotateTowards(transform.rotation, targetRotation, float.PositiveInfinity);
transform.rotation = finalRotation;
transform.position = hitInfo.point + hitInfo.normal * .5f;
}
}
private bool GetRaycastDownAtNewPosition(Vector3 movementDirection, out RaycastHit hitInfo)
{
Vector3 newPosition = transform.position;
Ray ray = new Ray(transform.position + movementDirection * Speed, -transform.up);
if (Physics.Raycast(ray, out hitInfo, float.PositiveInfinity, WorldLayerMask))
{
return true;
}
return false;
}
The Vector3 movementDirection
parameter is just as it sounds, the direction we are going to be moving our player in this frame, and calculating that vector, while ended up relatively simple in this example, was a bit tricky for me to figure out at first. More on that later, but just keep in mind that it's a normalized vector in the direction the player is moving this frame.
Stepping through, the first thing we do is check if a ray, originating at the hypothetical future position directed towards the players down vector (-transform.up) hits the world using WorldLayerMask which is a public LayerMask property of the script. If you want more complex collisions or multiple layers you will have to build your own layer mask. If the raycast successfully hits something the hitInfo is used to retrieve the normal and hit point to calculate the new position and rotation of the player which should be right on the object. Offsetting the player's position may be required depending on size and origin of the player object in question.
Finally, this has really only been tested and likely only works well on simple objects such as spheres. As the blog post I based my solution off of suggests, you will likely want to perform multiple raycasts and average them for your position and rotation to get a much nicer transition when moving over more complex terrain. There may also be other pitfalls I've not thought of at this point.
Camera and Movement
Once the player was sticking to the surface of the object the next task to tackle was movement. I had originally started out with movement relative to the player but I started running into issues at the poles of the sphere where directions suddenly changed making my player rapidly change direction over and over not letting me ever pass the poles. What I wound up doing was making my players movement relative to the camera.
What worked well for my needs was to have a camera that strictly followed the player based solely on the players position. As a result, even though the camera was technically rotating, pressing up always moved the player towards the top of the screen, down towards the bottom, and so on with left and right.
To do this, the following was executed on the camera where the target object was the player:
private void FixedUpdate()
{
// Calculate and set camera position
Vector3 desiredPosition = this.target.TransformPoint(0, this.height, -this.distance);
this.transform.position = Vector3.Lerp(this.transform.position, desiredPosition, Time.deltaTime * this.damping);
// Calculate and set camera rotation
Quaternion desiredRotation = Quaternion.LookRotation(this.target.position - this.transform.position, this.target.up);
this.transform.rotation = Quaternion.Slerp(this.transform.rotation, desiredRotation, Time.deltaTime * this.rotationDamping);
}
Finally, to move the player, we leveraged the transform of the main camera so that with our controls up moves up, down moves down, etc. And it is here we call UpdatePlayerTransform which will get our position snapped to the world object.
void Update ()
{
Vector3 movementDirection = Vector3.zero;
if (Input.GetAxisRaw("Vertical") > 0)
{
movementDirection += cameraTransform.up;
}
else if (Input.GetAxisRaw("Vertical") < 0)
{
movementDirection += -cameraTransform.up;
}
if (Input.GetAxisRaw("Horizontal") > 0)
{
movementDirection += cameraTransform.right;
}
else if (Input.GetAxisRaw("Horizontal") < 0)
{
movementDirection += -cameraTransform.right;
}
movementDirection.Normalize();
UpdatePlayerTransform(movementDirection);
}
To implement a more interesting camera but the controls to be about the same as what we have here you could easily implement a camera that isn't rendered or just another dummy object to base movement off of and then use the more interesting camera to render what you want the game to look like. This will allow nice camera transitions as you go around objects without breaking the controls.