Understanding QueueStateEvent & Input System Lifecycle

Currently, I’m building out a custom input device that passes its state to the system via QueueStateEvent in an OnUpdate callback. This custom device takes its state either from another “built-in” input device or from a third-party input API.

Here’s an exemplification of my issue: I press (without releasing) ButtonA which is bound to ActionA with an ActionType of Button and default interactions. My expected behavior is for the action to be “started” and “performed” once while pressed, with a “canceled” callback on release. However, I’m receiving repeated “performed” callbacks on seemingly every input system update while the ButtonA is pressed.

I think the issue is that because I’m queueing a new event state for my custom device on every update (even if the control values themselves are unchanged), actions are re-triggered.

So, is it my responsibility to track the state of each of the controls in my custom device and only queue state events for a control when its value has changed? I guess I naively expected the input system to just ingest state events and only trigger actions for controls that had changed state. Am I understanding this correctly? How would I best go about producing the expected behavior I outlined in my second paragraph? Any recommendation or best practices? Thanks in advance!

Input actions will not trigger if the state is bit by bit the same. An input action puts a state monitor (InputState.AddChangeMonitor) on the bit of state it is interested in. When state is modified and there’s a monitor, the state about to be written is memcmp’d to the state that’s current stored. If it’s different, it’ll set off the monitor and the action triggers.

Thanks for the explanation. So, the monitor shouldn’t qualify a state struct passed into the system via QueueStateEvent as a state change as long as its values are identical to the last struct passed in? Is the monitor tracking the overall state of the struct or the state of each individual control within the device state struct?

Yup.

The memory region of each individual control. Which, however, requires these regions to be set up correctly. If, for example, a control includes some garbage unused memory in its memory region and the contents of that garbage section change around (e.g. if someone constructs state events from uncleared memory), then the state monitors will get phantom triggers.

1 Like

Thanks for this @rdjadu - I think I’m following your explanation.

I came back to this issue after working on other things for a couple months, and I’m still having trouble. I can’t for the life of me figure out why, for example, a “Select” action bound to triggerPressed on my custom device is firing through all three phases (started, performed, cancelled) with every OnUpdate callback that the trigger is pressed. The value is not changing (as verified in the Input Debugger); but the action is refiring in its entirety with each update. Maybe, like you mention, I’m not setting up my memory regions correctly? If you have the chance, would you mind taking a look at the custom device and data class in question?

using UnityEngine;
public struct HandedXRControllerDeviceState : IInputStateTypeInfo
{
    public FourCC format => new FourCC(a: 'P', b: 'L', c: 'A', d: 'Y');
 
    [InputControl(layout = "Vector2")]
    public Vector2 thumbstick;

    [InputControl(layout = "Axis")]
    public float trigger;
    [InputControl(layout = "Axis")]
    public float grip;

    [InputControl(name = "triggerPressed", layout = "Button", bit = 0)]
    [InputControl(name = "gripPressed", layout = "Button", bit = 1)]
    [InputControl(name = "buttonSouth", layout = "Button", bit = 2)]
    [InputControl(name = "buttonNorth", layout = "Button", bit = 3)]
    [InputControl(name = "buttonSystem", layout = "Button", bit = 4)]
    [InputControl(name = "thumbstickClicked", layout = "Button", bit = 5)]
    [InputControl(name = "triggerTouched", layout = "Button", bit = 6)]
    [InputControl(name = "buttonSouthTouched", layout = "Button", bit = 7)]
    [InputControl(name = "buttonNorthTouched", layout = "Button", bit = 8)]
    [InputControl(name = "thumbstickTouched", layout = "Button", bit = 9)]
    public int buttons;
}

#if UNITY_EDITOR
[InitializeOnLoad]
#endif
[InputControlLayout(displayName = "HandedXRController", stateType = typeof(HandedXRControllerDeviceState), commonUsages = new string[] { "ActiveHand", "PassiveHand" })]
public class HandedXRControllerDevice : XRControllerWithRumble, IInputUpdateCallbackReceiver
{
public Vector2Control thumbstick { get; private set; }
public AxisControl trigger { get; private set; }
public AxisControl grip { get; private set; }
public ButtonControl triggerPressed { get; private set; }
public ButtonControl gripPressed { get; private set; }
public ButtonControl buttonSouth { get; private set; }
public ButtonControl buttonNorth { get; private set; }
public ButtonControl buttonSystem { get; private set; }
public ButtonControl thumbstickClicked { get; private set; }
public ButtonControl triggerTouched { get; private set; }
public ButtonControl buttonSouthTouched { get; private set; }
public ButtonControl buttonNorthTouched { get; private set; }
public ButtonControl thumbstickTouched { get; private set; }

protected override void FinishSetup()
{
    base.FinishSetup();

    thumbstick = GetChildControl<Vector2Control>("thumbstick");
    trigger = GetChildControl<AxisControl>("trigger");
    grip = GetChildControl<AxisControl>("grip");
    triggerPressed = GetChildControl<ButtonControl>("triggerPressed");
    gripPressed = GetChildControl<ButtonControl>("gripPressed");
    buttonSouth = GetChildControl<ButtonControl>("buttonSouth");
    buttonNorth = GetChildControl<ButtonControl>("buttonNorth");
    buttonSystem = GetChildControl<ButtonControl>("buttonSystem");
    thumbstickClicked = GetChildControl<ButtonControl>("thumbstickClicked");
    triggerTouched = GetChildControl<ButtonControl>("triggerTouched");
    buttonSouthTouched = GetChildControl<ButtonControl>("buttonSouthTouched");
    buttonNorthTouched = GetChildControl<ButtonControl>("buttonNorthTouched");
    thumbstickTouched = GetChildControl<ButtonControl>("thumbstickTouched");
}

#if UNITY_EDITOR
    static HandedXRControllerDevice() => RegisterLayout();
#endif

    [RuntimeInitializeOnLoadMethod]
    public static void RegisterLayout()
    {
        InputSystem.RegisterLayout(
            type: typeof(HandedXRControllerDevice),
            name: "HandedXRController",
            matches: new InputDeviceMatcher()
                .WithDeviceClass("^HandedXRController", supportRegex: true)
        );
    }

    public void OnUpdate()
    {
        // Copy state from generic XRController input device and queue this state for this custom device.
        HandedXRControllerDeviceState state = GetXRControllerState();

        InputSystem.QueueStateEvent(this, state);
    }

    private HandedXRControllerDeviceState GetXRControllerState()
    {
        HandedXRControllerDeviceState state = new HandedXRControllerDeviceState();

        // Finds the appropriate XRController device by handedness.
        InputDevice targetDevice = InputSystem.GetDevice<XRController>(
            usages.Contains(InputDeviceManager.XRUsages.ActiveHand)
            ? InputDeviceManager.ActiveHand
            : InputDeviceManager.PassiveHand
        );

        // If appropriate device was found, copy it's state to new state struct.
        if (targetDevice != null)
        {
            state.thumbstick = targetDevice.GetChildControl<Vector2Control>("thumbstick").value;
            state.trigger = targetDevice.GetChildControl<AxisControl>("trigger").value;
            state.grip = targetDevice.GetChildControl<AxisControl>("grip").value;

            if (targetDevice.GetChildControl<ButtonControl>("triggerPressed").isPressed)
                state.buttons |= 1 << 0;
            if (targetDevice.GetChildControl<ButtonControl>("gripPressed").isPressed)
                state.buttons |= 1 << 1;
            if (targetDevice.GetChildControl<ButtonControl>("primaryButton").isPressed)
                state.buttons |= 1 << 2;
            if (targetDevice.GetChildControl<ButtonControl>("secondaryButton").isPressed)
                state.buttons |= 1 << 3;
            if (targetDevice.GetChildControl<ButtonControl>("start").isPressed)
                state.buttons |= 1 << 4;
            if (targetDevice.GetChildControl<ButtonControl>("thumbstickClicked").isPressed)
                state.buttons |= 1 << 5;
            if (targetDevice.GetChildControl<ButtonControl>("triggerTouched").isPressed)
                state.buttons |= 1 << 6;
            if (targetDevice.GetChildControl<ButtonControl>("primaryTouched").isPressed)
                state.buttons |= 1 << 7;
            if (targetDevice.GetChildControl<ButtonControl>("secondaryTouched").isPressed)
                state.buttons |= 1 << 8;
            if (targetDevice.GetChildControl<ButtonControl>("thumbstickTouched").isPressed)
                state.buttons |= 1 << 9;
        }

        return state;
    }
}

Sorry to hear the thing is fighting you.

Nothing that readily stands out in the code to me. All the state change monitors certainly should be coming out just fine. Nothing tricky going on there in that state.

What I’d probably do next is look at the actual events. In the debugger, there’s an event trace at the bottom. Double-clicking an event pops up its contents. The trace can be paused so that you have time to sift through the data.

If there’s no button ups in the events, then the problem must indeed be elsewhere. Note that the if there’s multiple successive events with an intermittent reset but the eventual state being “pressed”, the button would still display as down in the debugger. It’s only in the event stream that sub-frame (or high-frequency) state changes can be observed in the debugger.

Also, out of curiosity, are you instantiating multiple of these devices in parallel? Are multiple controls (maybe from more than one of these devices) bound to the same action?

Sorry, just fishing here. Don’t really have a very specific suspicion :frowning:

No problem, I appreciate the help!

I looked into the event trace as you suggested. For as long as the button (in this case, “triggerPressed” - but behavior seems to apply to all controls) is held, there’s a consistent, rapid pattern of three events being logged. Two events wherein the value of triggerPressed is 1 and a third with the value 0. This seems to be consistent with the action rapidly phasing through “started”, “performed”, and “canceled”. I don’t really understand the raw memory views for these traced events, but I’m including a screen cap of the data from six sequential events which demonstrate the pattern I’m describing: https://imgur.com/a/AFzeVWM

As for your other questions - I am instantiating two of my custom devices, each with a different usage (“active”/“passive” handed), but I just tested with only one device being instantiated and behavior is the same. There is currently only one control from this device bound to the action.

Update! After further investigation, I discovered the issue is originating from the device I’m copying state from (in my case, an OculusTouchController) - not my custom device.

onUpdate is firing three times per frame while a button control is held. On the first two sub-frames, the control on the source device reads “1”; on the third sub-frame, the value reads “0” (Imgur: The magic of the Internet). I don’t know why this is happening, but it’s knowledge I can work with.

My solution is checking whether or not the source device has already been updated in the given frame, and if it has, I leave the state of my custom device as is.

    public void OnUpdate()
    {
        if (_sourceDevice == null)
        {
            _sourceDevice = InputSystem.GetDevice<XRController>(
                usages.Contains(InputDeviceManager.XRUsages.ActiveHand)
                    ? InputDeviceManager.ActiveHand
                    : InputDeviceManager.PassiveHand
            );

            if (_sourceDevice == null) return;
        }
       
        if (!_sourceDevice.wasUpdatedThisFrame) return;
       
        HandedXRControllerDeviceState state = (PlatformManager.INSTANCE.platform) switch
        {
            PlatformManager.Platform.OculusVR => GetXRControllerState(),
            PlatformManager.Platform.WebXR => GetWebXRControllerState(),
            _ => new HandedXRControllerDeviceState()
        };

        InputSystem.QueueStateEvent(this, state);
    }

Initial testing shows my issue is resolved with this workaround! That being said, it still feels like a workaround, and I’m going to have to test more extensively to make sure I’m not creating other issues. Don’t love fixing problems without fully understanding the cause. Anyway, that’s it for now. Thanks for your help on this!

Ah doh, I have a suspicion. Dunno why this didn’t occur to me earlier.

There’s a very annoying aspect to input updates which is that the editor gets its own updates. Complete with its own separate state. This doesn’t happen in the player but in the editor, InputUpdateType.Editor unfortunately is a thing.

So my guess is just doing

public void OnUpdate()
{
    #if UNITY_EDITOR
    if (InputState.currentUpdateType == InputUpdateType.Editor)
        return;
    #endif
    
    //...

Will solve the problem.

The state where the button is unpressed is pretty surely coming from the editor update where the device has not received any input.

2 Likes

Yep, that was it! Glad we got to the bottom of it - thanks for walking me through this.

1 Like