# Why is Vector3.ProjectOnPlane() "drifting" further from desired rotation on steep slopes

I'm writing a simple player movement script in Unity 3D, and I've been using Vector3.ProjectOnPlane() to accomplish slope/ramp movement. For the most part, this works on smaller slopes but I've noticed that the steeper the slope is, the more the direction seems to drift further away than expected, causing the player to walk further to one side than the input would suggest.

(To clarify, orientationTransform is rotated via the Mouse Input, thus only needing to use transform.forward/right in the movement calculation.)

Code:

// Excerpt from main movement loop
Vector3 movementDirection = (orientationTransform.forward.normalized * inputs.y + orientationTransform.right.normalized * inputs.x).normalized;

if (stateHandler.IsGrounded) {
if(stateHandler.IsOnSlope) {
Debug.DrawRay(orientationTransform.position, movementDirection.normalized * 3f, Color.magenta);
return GetVectorOnSlope(movementDirection) * movementForce * Time.fixedDeltaTime;
} else {
return movementDirection * movementForce * Time.fixedDeltaTime;
}
}

// Vector Function
Vector3 GetVectorOnSlope(Vector3 vector) {
RaycastHit hitInfo;
if (Physics.Raycast(orientationTransform.transform.position, Vector3.down, out hitInfo,
1f, groundLayer)) {
Debug.DrawRay(orientationTransform.position, Vector3.ProjectOnPlane(vector,
hitInfo.normal) * 2f, Color.red);
return Vector3.ProjectOnPlane(vector, hitInfo.normal).normalized;
} else {
return vector;
}
}


Example:
Desired Direction - Magenta
ProjectOnPlane Direction - Red

I've tried normalizing the result and experimenting with Vector3.Cross() as well as attempting to skip using Vector3.ProjectOnPlane() altogether, but I've not had much luck.

Is this the correct way for me to be implementing this? I feel like Vector3.ProjectOnPlane() is behaving exactly as it should, but I can't figure out an alternative to achieve what I'm looking for.

Any help is greatly appreciated!

• This looks as expected. Vector3.ProjectOnPlane creates a projection of a vector onto a plane. If the camera direction and the plane's normal are parallel, the two lines should overlap, otherwise they should not overlap. Jun 14 at 7:03
• You have a lot of unnecessary .normalized getters in there. forward and right are already unit vectors, so you don't need to normalize them. And you already normalized movementDirection when you assigned it, so you don't have to normalize it again when you read it. Jun 14 at 11:09
• Ah! I wasn't aware forward/right don't need normalized. Thanks for that! Jun 14 at 13:11

If you wanted to achieve a similar result without using projections, you might want to try just rotating the vector. This should be relatively easy to do knowing the normal of the plane.

I would take the cross product of Vector3.up and the plane normal to find the rotation axis, and then create a quaternion with the Quaternion.AngleAxis() function. The angle can be found with Vector3.SignedAngle(), using our newly calculated axis.

Once you have that, quaternion*vector should give you the rotated vector.

• I'll give this a try when I can later today, thanks for the suggestion! That sounds like it could be exactly what I'm looking for. Just so I'm sure I understand, with Vector3.SignedAngle, the to and from vector would be Vector3.Up with the plane normal? With the axis gained from the cross product being used as the rotational axis? Vector3 axis = Vector3.Cross(Vector3.up, hitInfo.normal); Vector3 angle = Vector3.SignedAngle(Vector3.up, hitInfo.normal, axis); Quaternion rotation = Quaternion.AngleAxis(angle, axis); Vector3 slopeMove = rotation * movementDirection; Jun 14 at 9:01
• Apologies for the formatting, I have no idea why double spacing isn't adding line breaks. Jun 14 at 9:07
• Yep, that is the idea. Note that the order of the vectors (both in the cross product and in the signed angle) matters. So, if the movement direction seems to be rotating the correct amount but in the wrong direction, changing the order of the arguments to one of those functions should do the trick. Jun 14 at 10:29
• This worked out absolutely brilliantly and exactly what I wanted! Thank you very much! The movement is smooth and rotates perfectly without the need to stop the typical "bumping" effect of slopes Jun 14 at 17:45
• Glad to hear that! Jun 15 at 6:12

I think you want something more like this:

// Use ClampMagnitude to allow analog movement up to but not exceeding a magnitude of 1.
// If using a force, do not multiply by delta time.
// (And if using a speed/displacement, don't name your variable "force" - that misleads)
var movement = Vector2.ClampMagnitude(inputs, 1.0f) * movementForce;

// Track the slope we're standing on - assume flat, level ground by default.
Vector3 intoTerrain = Vector3.down;

// Splitting out our ray struct helps avoid a long wrapping line in the if condition.
var ray = new Ray(orientationTransform.position, Vector3.down);
if ( Physics.Raycast(ray, out var hitInfo, 1f, groundLayer)) {
intoTerrain = -hitInfo.normal;
}

// Make an orientation where y+ points along the terrain in the "into the screen" direction
// and x+ points along the terrain in the "right / across the screen" direction.
var terrainOrientation = Quaternion.LookRotation(
intoTerrain,
Camera.main.transform.forward
);

// Transform our movement using this coordinate frame.
return terrainOrientation * movement;


• I'll test this out and let you know how it went once I have a chance later today, thanks for the different approach! Regarding the Vector2.ClampMagnitude(), as I normalize my movement direction, would this not have the same effect? Jun 14 at 13:10
• No, normalizing forces the magnitude to always be 1, even if the player has just barely nudged the analog stick. The ClampMagnitude style allows you to preserve analog input anywhere from 0 to 1, without ever exceeding 1. Jun 14 at 14:05
• That makes sense, neat! Jun 14 at 17:21
• Upon trying this, I found that my player control just stood perfectly still on the slope, as if frozen to the plane. This is for 3D movement, just to clarify? I'm going to debug this further and update if I manage to get this working Jun 14 at 17:39