Search Unity

  1. Welcome to the Unity Forums! Please take the time to read our Code of Conduct to familiarize yourself with the forum rules and how to post constructively.
  2. We have updated the language to the Editor Terms based on feedback from our employees and community. Learn more.
    Dismiss Notice
  3. Join us on November 16th, 2023, between 1 pm and 9 pm CET for Ask the Experts Online on Discord and on Unity Discussions.
    Dismiss Notice

Preventing character from bouncing down slopes with raycasts, is there a better way?

Discussion in 'Scripting' started by Murgilod, Jan 22, 2017.

  1. Murgilod

    Murgilod

    Joined:
    Nov 12, 2013
    Posts:
    9,764
    I have a character controller that works swimmingly provided you go up slopes or stick to flat ground, but if you go down slopes it bounces all the way down.

    My first instinct was to solve this with raycasts, which I did with an easy enough function here:

    Code (CSharp):
    1.     bool isGrounded () {
    2.         Vector3 origin = controller.transform.position - new Vector3 (0f, controller.height / 2f, 0f);
    3.         RaycastHit hit;
    4.  
    5.         if (controller.isGrounded) {
    6.             return true;
    7.         }
    8.  
    9.         if (Physics.Raycast(origin, -Vector3.up, out hit, groundCheckDistance, ~(gameObject.layer))){
    10.             controller.Move(new Vector3(0f, -hit.distance, 0f));
    11.             return true;
    12.         }
    13.  
    14.         return false;
    15.     }
    Now, this works, but it comes with enough caveats that I've determined that it's not a valid solution at all. They're as follows:
    1. The faster the controller is moving (I'm going for old-school 90s FPS style movement, so fast enough this is a problem), the larger the value groundCheckDistance needs to be.
    2. The larger groundCheckDistance is, the more the player snaps to the ground when coming out of a jump.
    3. The raycasts rarely hit when on slopes steeper than my 45 degree limit unless I extend them even more, which exacerbates problems 2 and 3.
    I tried implementing Garth Smith's SafeMove function but I couldn't integrate it nicely into my character controller at all.

    Are there any solutions to this predicament? For those interested, here's my full character controller (with the function currently not active as I can't abide by its buffoonery). It's mostly a straight conversion of Zinglish's Quake 3 movement script but with a couple added features and tweaks.

    Code (CSharp):
    1. using System.Collections;
    2. using System.Collections.Generic;
    3. using UnityEngine;
    4. using Rewired;
    5.  
    6. [RequireComponent(typeof(CharacterController))]
    7. public class FPSController : MonoBehaviour {
    8.  
    9.     public int playerID;
    10.     private Player player;
    11.     private CharacterController controller;
    12.     public Camera FPSCamera;
    13.  
    14.     public float cameraYOffset = 0.5f;
    15.     public float mouseXSensitivity = 30f;
    16.     public float mouseYSensitivity = 30f;
    17.  
    18.     public float friction = 6f;
    19.  
    20.     public float moveSpeed = 7f;
    21.     public float runAcceleration = 14f;
    22.     public float runDecceleration = 10f;
    23.     public float airAcceleration = 2f;
    24.     public float airDecceleration = 2f;
    25.     public float airControl = 0.3f;
    26.     public float strafeSpeed = 1f;
    27.     public float strafeAcceleration = 50f;
    28.     public float jumpSpeed = 8.0f;
    29.     public float moveScale = 1.0f;
    30.     public float wishSpeedGlobal = 2f;
    31.     public float groundCheckDistance = 0.25f;
    32.  
    33.     public bool isGrappling = false;
    34.     public bool isDead = false;
    35.  
    36.     public AudioClip[] jumpSounds;
    37.     AudioSource audio;
    38.  
    39.     private float cameraRotX = 0f;
    40.     private float cameraRotY = 0f;
    41.  
    42.     private Vector3 moveDirection = Vector3.zero;
    43.     private Vector3 moveDirectionNorm = Vector3.zero;
    44.     public Vector3 playerVelocity = Vector3.zero;
    45.     private float playerTopVelocity = 0f;
    46.  
    47.     private bool wishJump = false;
    48.  
    49.     private float playerFriction = 0f;
    50.  
    51.     private Vector3 contactPoint;
    52.  
    53.     public class Cmd {
    54.         public float forwardMove;
    55.         public float rightMove;
    56.         public float upMove;
    57.     }
    58.  
    59.     protected Cmd cmd;
    60.  
    61.     void Start () {
    62.         player = ReInput.players.GetPlayer (playerID);
    63.         controller = GetComponent<CharacterController> ();
    64.         FPSCamera = GameObject.FindGameObjectWithTag ("PlayerCamera").GetComponent<Camera> ();
    65.  
    66.         Cursor.visible = false;
    67.         Cursor.lockState = CursorLockMode.Locked;
    68.  
    69.         cmd = new Cmd();
    70.  
    71.         audio = GetComponent<AudioSource> ();
    72.     }
    73.  
    74.     public float cmdScale() {
    75.         float max;
    76.         float total;
    77.  
    78.         max = Mathf.Abs(cmd.forwardMove);
    79.         if (Mathf.Abs(cmd.rightMove) > max)
    80.             max = Mathf.Abs(cmd.rightMove);
    81.         if (max <= 0f) // Check (!max in js)
    82.             return 0f;
    83.  
    84.         total = Mathf.Sqrt(cmd.forwardMove * cmd.forwardMove + cmd.rightMove * cmd.rightMove);
    85.         return moveSpeed * max / (moveScale * total);
    86.     }
    87.  
    88.     void PlayJumpSound() {
    89.         if (audio.isPlaying) {
    90.             return;
    91.         }
    92.  
    93.         audio.clip = jumpSounds [Random.Range (0, jumpSounds.Length)];
    94.         audio.Play();
    95.     }
    96.  
    97.     //Movement
    98.     void SetMovementDir() {
    99.         cmd.forwardMove = player.GetAxisRaw ("MoveForward");
    100.         cmd.rightMove = player.GetAxisRaw ("Strafe");
    101.     }
    102.  
    103.     void QueueJump() {
    104.         if (player.GetButtonDown ("Jump") && !wishJump) {
    105.             wishJump = true;
    106.         }
    107.  
    108.         if (player.GetButtonUp ("Jump")) {
    109.             wishJump = false;
    110.         }
    111.     }
    112.  
    113.     void Accelerate(Vector3 wishDir, float wishSpeed, float accel) {
    114.         float addSpeed;
    115.         float accelSpeed;
    116.         float currentSpeed;
    117.  
    118.         currentSpeed = Vector3.Dot (playerVelocity, wishDir);
    119.         addSpeed = wishSpeed - currentSpeed;
    120.  
    121.         if (addSpeed <= 0f) {
    122.             return;
    123.         }
    124.  
    125.         accelSpeed = accel * Time.deltaTime * wishSpeed;
    126.  
    127.         if (accelSpeed > addSpeed) {
    128.             accelSpeed = addSpeed;
    129.         }
    130.  
    131.         playerVelocity.x += accelSpeed * wishDir.x;
    132.         playerVelocity.z += accelSpeed * wishDir.z;
    133.     }
    134.  
    135.     void AirControl(Vector3 wishDir, float wishSpeed) {
    136.         float zSpeed;
    137.         float speed;
    138.         float dot;
    139.         float k;
    140.  
    141.         if (cmd.forwardMove == 0f || wishSpeed == 0f) {
    142.             return;
    143.         }
    144.  
    145.         zSpeed = playerVelocity.y;
    146.         playerVelocity.y = 0f;
    147.  
    148.         speed = playerVelocity.magnitude;
    149.         playerVelocity.Normalize ();
    150.  
    151.         dot = Vector3.Dot (playerVelocity, wishDir);
    152.         k = 32f;
    153.         k *= airControl * dot * dot * Time.deltaTime;
    154.  
    155.         if (dot > 0f) {
    156.             playerVelocity = new Vector3 (playerVelocity.x * speed * wishDir.x * k,
    157.                 playerVelocity.y * speed * wishDir.y * k,
    158.                 playerVelocity.z * speed * wishDir.z * k);
    159.  
    160.             playerVelocity.Normalize ();
    161.         }
    162.  
    163.         playerVelocity *= speed;
    164.         playerVelocity.y = zSpeed;  
    165.     }
    166.  
    167.     void AirMove() {
    168.         Vector3 wishDir;
    169.         float wishVelocity = airAcceleration;
    170.         float accel;
    171.  
    172.         float scale = cmdScale ();
    173.  
    174.         SetMovementDir ();
    175.  
    176.         wishDir = new Vector3 (cmd.rightMove, 0f, cmd.forwardMove);
    177.         wishDir = transform.TransformDirection (wishDir);
    178.         wishDir.Normalize ();
    179.         moveDirection = wishDir;
    180.  
    181.         float wishSpeed = wishDir.magnitude;
    182.         wishSpeed *= moveSpeed;
    183.  
    184.         float wishSpeed2 = wishSpeed;
    185.         if (Vector3.Dot (playerVelocity, wishDir) < 0f) {
    186.             accel = airDecceleration;
    187.         } else {
    188.             accel = airAcceleration;
    189.         }
    190.  
    191.         if (cmd.forwardMove == 0f && cmd.rightMove != 0f) {
    192.             if (wishSpeed > wishSpeedGlobal) {
    193.                 wishSpeed = wishSpeedGlobal;
    194.             }
    195.             accel = strafeAcceleration;
    196.  
    197.             Accelerate (wishDir, wishSpeed, accel);
    198.  
    199.             if (airControl != 0f) {
    200.                 AirControl (wishDir, wishSpeed2);
    201.             }
    202.         } else {
    203.             Accelerate (wishDir, wishSpeed, accel);
    204.         }
    205.  
    206.         if ((controller.collisionFlags == CollisionFlags.Above) == true) {
    207.             if (playerVelocity.y > 0) {
    208.                 playerVelocity.y = 0f;
    209.             }
    210.         }
    211.  
    212.         playerVelocity.y += Physics.gravity.y * Time.deltaTime;
    213.     }
    214.  
    215.     void ApplyFriction (){
    216.         Vector3 vec = playerVelocity;
    217.         float speed;
    218.         float newSpeed;
    219.         float control;
    220.         float drop;
    221.  
    222.         vec.y = 0f;
    223.         speed = vec.magnitude;
    224.  
    225.         drop = 0f;
    226.  
    227.         if (controller.isGrounded) {
    228.             control = speed < runDecceleration ? runDecceleration : speed;
    229.             drop = control * friction * Time.deltaTime;
    230.         }
    231.  
    232.         newSpeed = speed - drop;
    233.         playerFriction = newSpeed;
    234.  
    235.         if (newSpeed < 0f) {
    236.             newSpeed = 0f;
    237.         }
    238.         if (speed > 0f) {
    239.             newSpeed /= speed;
    240.         }
    241.  
    242.         playerVelocity.x *= newSpeed;
    243.         playerVelocity.z *= newSpeed;
    244.     }
    245.  
    246.     void GroundMove() {
    247.         Vector3 wishDir;
    248.  
    249.         if (!wishJump) {
    250.             ApplyFriction ();
    251.         }
    252.  
    253.         float scale = cmdScale ();
    254.  
    255.         SetMovementDir ();
    256.  
    257.         wishDir = new Vector3 (cmd.rightMove, 0f, cmd.forwardMove);
    258.         wishDir = transform.TransformDirection (wishDir);
    259.         wishDir.Normalize ();
    260.         moveDirection = wishDir;
    261.  
    262.         float wishSpeed = wishDir.magnitude;
    263.         wishSpeed *= moveSpeed;
    264.  
    265.         Accelerate (wishDir, wishSpeed, runAcceleration);
    266.  
    267.         playerVelocity.y = 0f;
    268.  
    269.         if (wishJump) {
    270.             playerVelocity.y = jumpSpeed;
    271.             wishJump = false;
    272.             PlayJumpSound();
    273.         }
    274.     }
    275.  
    276.     void SafeMove(Vector2 velocity) {
    277.         Vector3 displacement;
    278.         displacement.x = velocity.x * Time.deltaTime;
    279.         displacement.y = 0f;
    280.         displacement.z = -controller.transform.position.z;
    281.         controller.Move (displacement);
    282.  
    283.         displacement.y = velocity.y * Time.deltaTime;
    284.  
    285.         if (-Mathf.Abs (displacement.x) < displacement.y && displacement.y < 0) {
    286.             displacement.y = -Mathf.Abs (displacement.x) - 0.001f;
    287.         }
    288.  
    289.         displacement.z = 0f;
    290.         displacement.x = 0f;
    291.         controller.Move (displacement);
    292.     }
    293.  
    294.     bool isGrounded () {
    295.         Vector3 origin = controller.transform.position - new Vector3 (0f, controller.height / 2f, 0f);
    296.         RaycastHit hit;
    297.  
    298.         if (controller.isGrounded) {
    299.             return true;
    300.         }
    301.  
    302.         if (Physics.Raycast(origin, -Vector3.up, out hit, groundCheckDistance, ~(gameObject.layer))){
    303.             controller.Move(new Vector3(0f, -hit.distance, 0f));
    304.             return true;
    305.         }
    306.  
    307.         return false;
    308.     }
    309.  
    310.     void Update () {
    311.         if (!isDead) {
    312.             cameraRotX -= player.GetAxisRaw ("LookVertical") * mouseXSensitivity * Time.deltaTime;
    313.             cameraRotY += player.GetAxisRaw ("LookHorizontal") * mouseYSensitivity * Time.deltaTime;
    314.         }
    315.  
    316.         if (cameraRotX < -90f) {
    317.             cameraRotX = -90f;
    318.         } else if (cameraRotX > 90f) {
    319.             cameraRotX = 90f;
    320.         }
    321.  
    322.         transform.rotation = Quaternion.Euler (0f, cameraRotY, 0f);
    323.         FPSCamera.transform.rotation = Quaternion.Euler (cameraRotX, cameraRotY, 0f);
    324.  
    325.         QueueJump ();
    326.         if (controller.isGrounded && !isGrappling && !isDead) {
    327.             GroundMove ();
    328.         } else if (!controller.isGrounded && !isGrappling && !isDead) {
    329.             AirMove ();
    330.         }
    331.  
    332.         if (isDead) {
    333.             playerVelocity = new Vector3 (0f,0f,0f);
    334.         }
    335.  
    336.         controller.Move (playerVelocity * Time.deltaTime);
    337.  
    338.     }
    339. }
     
  2. eses

    eses

    Joined:
    Feb 26, 2013
    Posts:
    2,637
    @Murgilod

    Hi there,

    didn't check your code, but how about this;

    make ground check ray value bigger when grounded, when you know you are in air, then make the grounded check ray shorter, until you are again grounded?
     
  3. Murgilod

    Murgilod

    Joined:
    Nov 12, 2013
    Posts:
    9,764
    That only solves problem 2, which is part of the reason why I don't find raycasts to be an elegant solution for this sort of problem.

    Taking cues from this post, I've modified my ground code. I'll point out the important changes.

    Code (CSharp):
    1.     void GroundMove() {
    2.         Vector3 wishDir;
    3.  
    4.         if (!wishJump) {
    5.             ApplyFriction ();
    6.         }
    7.  
    8.         float scale = cmdScale ();
    9.  
    10.         SetMovementDir ();
    11.  
    12.         wishDir = new Vector3 (cmd.rightMove, 0f, cmd.forwardMove);
    13.         wishDir = transform.TransformDirection (wishDir);
    14.         wishDir.Normalize ();
    15.         moveDirection = wishDir;
    16.  
    17.         float wishSpeed = wishDir.magnitude;
    18.         wishSpeed *= moveSpeed;
    19.  
    20.         Accelerate (wishDir, wishSpeed, runAcceleration);
    21.  
    22. //        playerVelocity.y = 0f;
    23.         playerVelocity.y = -controller.stepOffset / Time.deltaTime; //THIS IS NEW
    24.  
    25.         if (wishJump) {
    26.             playerVelocity.y = jumpSpeed;
    27.             wishJump = false;
    28.             PlayJumpSound();
    29.         }
    30.     }
    And my update code is slightly changed as well.

    Code (CSharp):
    1. void Update () {
    2.         wasGrounded = controller.isGrounded;
    3.  
    4.         if (!isDead) {
    5.             cameraRotX -= player.GetAxisRaw ("LookVertical") * mouseXSensitivity * Time.deltaTime;
    6.             cameraRotY += player.GetAxisRaw ("LookHorizontal") * mouseYSensitivity * Time.deltaTime;
    7.         }
    8.  
    9.         if (cameraRotX < -90f) {
    10.             cameraRotX = -90f;
    11.         } else if (cameraRotX > 90f) {
    12.             cameraRotX = 90f;
    13.         }
    14.  
    15.         transform.rotation = Quaternion.Euler (0f, cameraRotY, 0f);
    16.         FPSCamera.transform.rotation = Quaternion.Euler (cameraRotX, cameraRotY, 0f);
    17.  
    18.         QueueJump ();
    19.         if (controller.isGrounded && !isGrappling && !isDead) {
    20.             GroundMove ();
    21.         } else if (!controller.isGrounded && !isGrappling && !isDead) {
    22.             AirMove ();
    23.         }
    24.  
    25.         if (isDead) {
    26.             playerVelocity = new Vector3 (0f,0f,0f);
    27.         }
    28.  
    29.         controller.Move (playerVelocity * Time.deltaTime);
    30.  
    31.         if (!controller.isGrounded && wasGrounded && playerVelocity.y < 0f) { //THIS IS NEW
    32.             playerVelocity.y = 0f;
    33.         }
    34.     }
    This partially works. It allows me to walk off of edges and have gravity apply in a way that would make sense. The problem is that for a single frame, you're falling at a speed of -controller.stepOffset / Time.deltaTime until the speed is set to 0f. I'm not sure what I can do to overcome this problem, and it makes it so there's inconsistent rules in regards to gravity, so I can't really keep it that way.
     
  4. Murgilod

    Murgilod

    Joined:
    Nov 12, 2013
    Posts:
    9,764
    Okay, so an update on this. I know what I should be doing, which is a simple matter of
    1. Force the player down when grounded
    2. Disable this force while jumping
    3. Check the previous grounded state
    4. Disable this force when entering free-fall
    5. Set the previous grounded state to the current grounded state
    Unfortunately, my attempts to store the previous grounded state of the character controller have all been futile. It ALWAYS registers the same as the current grounded state unless I update the move vector first, which is why there's a downward hop when walking off ledges. Is there a way around this?
     
  5. Sabo

    Sabo

    Joined:
    Nov 7, 2009
    Posts:
    151
    I only browsed the issue (sorry), but, what about getting the angle of the ground and use that information to create a movement direction along the downward slope? I do not know if your forward / right vectors always are aligned with the world axes or not. I kind of assume they are, else the forward vector should be poiting along the slope, no?

    If creating a movement direction along the slope is an option you should be able to get the angle of the slope with Vector2.Angle(hit.normal, Vector2.up).
     
  6. Murgilod

    Murgilod

    Joined:
    Nov 12, 2013
    Posts:
    9,764
    I've solved the bouncing issue by applying a downward force of -controller.stepOffset / Time.deltaTime when the player is grounded. Now the issue is getting the grounding data from the previous frame without updating the character controller's position.
     
  7. Sabo

    Sabo

    Joined:
    Nov 7, 2009
    Posts:
    151
    I must be interpreting this the wrong way, but saving data for the next frame should be as easy as creating a private variable / variables and updating them at the end of each frame? What am I missing?
     
  8. Murgilod

    Murgilod

    Joined:
    Nov 12, 2013
    Posts:
    9,764
    See, that's what I thought as well. However, I'm doing just that and it doesn't seem to get along well with the character controller at all.

    Code (CSharp):
    1.     void Update () {
    2.  
    3.         if (!isDead) {
    4.             cameraRotX -= player.GetAxisRaw ("LookVertical") * mouseXSensitivity * Time.deltaTime;
    5.             cameraRotY += player.GetAxisRaw ("LookHorizontal") * mouseYSensitivity * Time.deltaTime;
    6.         }
    7.  
    8.         if (cameraRotX < -90f) {
    9.             cameraRotX = -90f;
    10.         } else if (cameraRotX > 90f) {
    11.             cameraRotX = 90f;
    12.         }
    13.  
    14.         transform.rotation = Quaternion.Euler (0f, cameraRotY, 0f);
    15.         FPSCamera.transform.rotation = Quaternion.Euler (cameraRotX, cameraRotY, 0f);
    16.  
    17.         QueueJump ();
    18.         if (controller.isGrounded && !isGrappling && !isDead) {
    19.             GroundMove ();
    20.         } else if (!controller.isGrounded && !isGrappling && !isDead) {
    21.             AirMove ();
    22.         }
    23.  
    24.         if (isDead) {
    25.             playerVelocity = new Vector3 (0f,0f,0f);
    26.         }
    27.          
    28.         if (!controller.isGrounded && wasGrounded && playerVelocity.y < 0f) {
    29.             playerVelocity.y = 0f;
    30.             Debug.Log (controller.velocity.y);
    31.         }
    32.  
    33.         controller.Move (playerVelocity * Time.deltaTime);
    34.  
    35.         wasGrounded = controller.isGrounded;
    36.  
    37.     }
    By all accounts, line 28 should be executing just fine and zeroing out the y velocity, but it doesn't trigger at all unless I put it below controller.Move, which leads to the downward hop I'm experiencing right now.

    edit: I just tested with a super simplified character controller script and it seems that this is a problem with Unity's CharacterController in general, which is... annoying.
     
    Last edited: Jan 23, 2017
  9. Sabo

    Sabo

    Joined:
    Nov 7, 2009
    Posts:
    151
    Is "controller" a reference to some Unity class? If so, and if "controller.isGrounded" does not do what you want (as in, saving the isGrounded state between frames), how about creating a bool, isGrounded, inside your class containing the controller class and save the state there, not relying on the controller.isGrounded. You then update your own isGrounded variable using controller.isGrounded at the end of each Update.
     
  10. Laperen

    Laperen

    Joined:
    Feb 1, 2016
    Posts:
    1,065
    My solution to slopes was to get the ground surface collision normal, and adapt the movement direction to it. This guarantees your characters will never leave the ground on a slope since they move along the slope.

    For a 2D side scroller game this is easily done by cross multiplying the collision normal with Vector3.forward, or -Vector3.forward, to get your movement direction.

    Code (CSharp):
    1. float speed;
    2. Vector3 upVect;
    3.  
    4. void FixedUpdate(){
    5.     float hori = GetAxisRaw("Horizontal");//player controls
    6.     Vector3 moveVect = Vector3.Cross(upVect, Vector3.forward);
    7.     GetComponent<Rigidbody2D>().velocity = moveVect * hori * speed;//sample application of movement direction in action
    8. }
    9. void OnCollisionStay2D(Collision2D col){
    10.     if(col.contacts[0].normal.y > 0.7f){//check if ground is walkable, in this case 45 degrees and lower
    11.         upVect = col.contacts[0].normal;//set up direction
    12.     }
    13. }
    For a 3D game this is slightly harder to understand but totally doable. you have to cross multiply the collision normal with your controls done sideways.
    Code (CSharp):
    1. float speed;
    2. Vector3 upVect;
    3.  
    4. void FixedUpdate(){
    5.     Vector3 moveVect = new Vector3(GetAxisRaw("Vertical"), 0, -GetAxisRaw("Horizontal"));//sideways player controls
    6.     moveVect = Vector3.Cross(upVect, moveVect);//set move direction
    7.     GetComponent<Rigidbody>().velocity = moveVect * speed;//sample application of movement direction
    8. }
    9. void OnCollisionStay(Collision col){
    10.     if(col.contacts[0].normal.y > 0.7f){//check if ground is walkable, in this case 45 degrees and lower
    11.         upVect = col.contacts[0].normal;//set up direction
    12.     }
    13. }
    All this is just sample application to get a taste of how it works, you will have to adapt it for your own use. In this case i have used rigidbodies rather than character controllers, but as long as you can get the collision normal of the surface you are walking on, this method should work.