I make stuff

◄ Previous article Next article ►

AV-Racer Devlog (5): Writing a game controller rumble event manager

Tuesday, August 09, 2022

est. reading time: 9 minutes

Deciding to maintain multi-platform compatibility of this algorithm by avoiding Microsoft's XInput or DirectInput API, and taking advantage of the API I already use, I used SDL's API to handle controller vibration. The functions used, however, are quite similar to ones used by individual drivers, so the algorithm can be used there too.

In the API you find two functions to control a typical gamepad's rumble effects:


    SDL_GameControllerRumble(SDL_GameController *gamecontroller,
                             Uint16 low_frequency_rumble, 
                             Uint16 high_frequency_rumble, 
                             Uint32 duration_ms);
    SDL_GameControllerRumbleTriggers(SDL_GameController *gamecontroller,
                                     Uint16 left_rumble,
                                     Uint16 right_rumble, 
                                     Uint32 duration_ms);
          
The XInput equivalent is:

        struct XINPUT_VIBRATION 
        {
            WORD wLeftMotorSpeed;
            WORD wRightMotorSpeed;
        };

        DWORD XInputSetState(DWORD dwUserIndex, XINPUT_VIBRATION *pVibration);
        
The principle is the same, but we'll stick with SDL's function for this example as it is what was used in AV-Racer.

The first one controls low and high frequency rumbles on the left and right sides of the gamepad, the second controls the higher frequency localized vibration devices behind each of the triggers. Other controllers have more vibration devices and effects, but the most commonly used gamepads offer these two groups, or at least the former. SDL wraps different driver functions and those too pack high and low frequency vibration commands into singular calls so we are forced to work with this limitaion.

The problem with these functions is that whenever they are called, they stop the last rumble and start a new one with fresh values, "overwriting" the last request. What if we wanted the controller to have interlaced rumbling? Say we are driving a car in the game on the gravel, the controller should be rumbling lightly to convey the feel of the road, then we hit an object on the road, then continue on the gravel. We would want the controller to keep rumbling at a low intensity, spike to a higher one when hitting the object, then continue with the lower intensity.


With SDL, a function call cancels the previous one, so if we had a running rumble effect, it gets canceled, and we get choppy behavior. So, let's write a system that allows us while developing the game to easily push rumble events with variable locations, durations, and intensity, in an immediate fashion, and have one "play" call in the end that handles the compilation and execution of these requests over multiple frames.

Organizing the data

Let us start with simple data structures. SDL initiates a rumble effect of two rumble devices within the controller per call. So, we need to first split those rumble "groups" and handle individual devices. We want to be able to control each of the four devices within the controller individually.

    enum rumbletype{
        RUMBLE_LOW,
        RUMBLE_HIGH,
        RUMBLE_TRIGGER_L,
        RUMBLE_TRIGGER_R
    };
    struct rumble_event{
        int ControllerIndex;
        rumbletype Type;
        int Duration;
        float Gain;
    };
        
Now since we are feeding all that info into two SDL functions who treat every two of those types (low, and high) and (left trigger, right trigger) each as a unit group in a single call, it would be useful to group that info into structures on the stack, especially since we are planning to handle rumbling events that run over multiple frames. Then let's wrap all of this in a single structure that includes a master gain dial to adjust global vibration intensity.

    
    
    struct rumble_group_state{
        int Duration = 0;
        float Gain[2] = {0};
    };
    struct rumble{
        rumble_event *RumbleEvents;
        
        
        rumble_group_state RumbleState[4][2]; 
        float rumble_gain = 1;
        int RumbleEventCount;
    };

The implementation

We need two functions, a "push" and a "play". The push function gets called multiple times anywhere in the code and queues rumble events, and the play function is called once at the end of the frame where we send these rumble commands through SDL.


 
    void PushRumble(int ControllerID, rumbletype Type, int Duration_ms, float gain){
        if (gain <= 0)
            return;
        Rumble.Events = (rumble_event *)realloc(Rumble.Events, sizeof(rumble_event) * (Rumble.EventCount + 1));
        Rumble.Events[G->Keys.Rumble.EventCount].ControllerIndex = ControllerID;
        Rumble.Events[G->Keys.Rumble.EventCount].Type = Type;
        Rumble.Events[G->Keys.Rumble.EventCount].Duration = Duration_ms;
        Rumble.Events[G->Keys.Rumble.EventCount].Gain = Gain;
        Rumble.EventCount++;
    }
    
The function PushRumble takes a controller ID that is user created to organize controllers, the specific type of rumble effect, i.e. what rumble device within the controller we want to use, a duration of the rumble in miliseconds and 0 to 1 float to control rumble intensity.

The function basically pushes an event to the stack filled with this information, skipping if the gain is zero to avoid unnecessary calls. Here's the code, how the algorithm works is explained right afterwards:

    tatic void PlayRumbles()
    {
       static int time = 0; 
       
       
       float duration[4][2] = {0};    

       
       for (int i = 0; i < 4; i++)                  
       {
           duration[i][0] = Rumble.RumbleState[i][0].duration;
           duration[i][1] = Rumble.RumbleState[i][1].duration;
       }
       
       







       for (int i = 0; i < Rumble.EventCount; i++)
       {
           rumble_event *E = &Rumble.Events[i];                                                       
           int g = int(E->Type / 2);                                                                  
                    int n = E->Type % 2;                                                              
           rumble_group_state *S = &Rumble.RumbleState[E->ControllerIndex][g];                        
           duration[E->ControllerIndex][g] = max(duration[E->ControllerIndex][g], E->Duration);       
           S->Duration = duration[E->ControllerIndex][g];                                             
           S->Gain[n] = max(S->Gain[n], E->Gain) * Rumble.rumble_gain;                                
       }
    
       for (int i = 0; i < 4; i++)
       {
           rumble_group_state *S[2];
           S[0] = &Rumble.RumbleState[i][0];
           S[1] = &Rumble.RumbleState[i][1];
    
           gamecontroller *Controller = &G->Keys.GameController[i];
    
           
           SDL_GameControllerRumble(Controller->GameController, 0xFFFF * S[0]->Gain[0], 
                                    0xFFFF * S[0]->Gain[1], S[0]->Duration);
           SDL_GameControllerRumbleTriggers(Controller->GameController, 0xFFFF * S[1]->Gain[0],
                                            0xFFFF * S[1]->Gain[1], S[1]->Duration);
           
           
           int time_diff = SDL_GetTicks() - time;    

           
           S[0]->Duration -= time_diff;                                  
           S[1]->Duration -= time_diff;
           
           
           if (S[0]->Duration <= 10)                                   
           {
               S[0]->Gain[0] = 0;
               S[0]->Gain[1] = 0;
               S[0]->Duration = 0;
           }
           if (S[1]->Duration <= 10)
           {
               S[1]->Gain[0] = 0;
               S[1]->Gain[1] = 0;
               S[1]->Duration = 0;
           }
       }
    
       
       time = SDL_GetTicks();                                      
       Rumble.EventCount = 0;                                       
    }
Here's how the algorithm works:

We loop over queued events and find the relevant rumble_group_state that we want to update, that struct holds the vibration data over multiple frames. By the nature of SDL's calls there is one limitation: Say we have the following graphed case:



Here the frames are visualized with 60 FPS which means a frame is around 16.67 ms long. The colored boxes are queued rumble events called by PushRumble() and each has a different duration value. Ideally we would want the controllers to vibrate exactly this much, however we are limited to the frame update frequency to be able to adjust our calls and we can't influence sub frame updates on the controller sides besides requesting them and sending them to the device's controller. Unless we want to delegate that task to another thread and even then we are dependent on the main thread to update the requests.

The limitation of using group based calls also means that when sending a rumble effect request to the device we need to specify one duration, as per the function. So we either have to settle for the longer or the shorter call. We could still modulate the intensity individually but we can't adjust the duration individually. Deciding to sacrifice the smaller for the larger would have more favorable results as we would want to feel the longer one of the two rather than have a choppy vibration. So the above request looks like this in the end if we went for the longer of the two:



Notice the above group isn't affected in this case because the calls are separated by frame. Our real limitation in the end is how often we can send requests to the device. The end algorithm would require for group 1 to have 3 calls, at frame 0 to push 16.67 ms, at frame 1 the same, and at frame 2 to push 1.68ms.

Settling for the above compromise we can then store the duration of the request in a local duration float that lives during the scope of the function and holds the largest value of the duration within this frame. For the gain, we simply send the gain of the largest queued rumble event, as logically the stronger rumble would dwarf the smaller ones within the one vibration device. After that for loop, we would have collected the relevant information we want to send the device this frame, the next step is to integrate this information into the rumbling state over multiple frames.

Here is where the value duration and the static int time value come in. Basically we declare a static integer that stores the number of ms since the program start to this point at the time the function ends in order to calculate how much time was passed in ms in the next frame when the function gets called. This time_diff is used to substract from the duration value in the rumble_group_state and is a way to know how much time the controller was rumbling, and update the next call with the reminder of the time plus any additional time added during this loop.

At the end of the function we set the duration and gains to zero if below a certain threshold that isn't zero to accommodate for ms calculation inaccuracy, this value can be something smaller than the frame length. We set the time, and reset the EventCount to allow next frame to start counting event from zero.

Applying this allows the following calls, for example, to be made:

    PushRumble(ControllerID, RUMBLE_LOW, 150, 0.2);
    PushRumble(ControllerID, RUMBLE_HIGH, 210, 0.85);
    PushRumble(ControllerID, RUMBLE_TRIGGER_L, 20, 0.8);
    …
    PlayRumbles();
You can see this in action when playing AV-Racer on a controller. So now with some code we managed to have a rumble event manager with enough flexibility to use in a game. Looking over the implementation of XInput and PS controller inputs, they all send packets of two values for the high and low frequency motors instead of having a function that controls each. SDL's implementation is then forced to follow the convention. I personally find it a bizzare decision on the driver API to pack these togther. A possible reason is to support old conventions and backwards compatibility. This doesn't mean that it isn't possible to write two new functions that allow more control.

-Wassim
◄ Previous article Next article ►