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