-
-
Couldn't load subscription status.
- Fork 441
Multi-Backend Input for Silk.NET 3.0 #554
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
This is ready for pre-meeting review. |
|
Please rebase proposal/3.0-input-llapi to not include all the 2.7 changes. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See comments.
Also, please elaborate a bit on how these new types are supposed to work. I'm just massively confused what the interactions are and what the design is here.
|
|
||
| ## Source Generator | ||
|
|
||
| Alongside the input package there will be a source generator (shipped in a `Silk.NET.Input.Roslyn` NuGet package, which is referenced by the main input package). This source generator will be very small, but it allows us to create a link between windowing and input at compile time without a hard reference. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why in a separate package? at this point I'm pretty confident one can ship both in one package.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not entirely convinced this is possible, they're structured differently and even if we can NuGet will not be able to distinguish dependencies.
|
Can I get a follow-up on this and resolve discussions if you think they're fine? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking pretty good now. I'm not fully convinced it's good to have Add as a normal method still, I think the InputContext also calling the constructor might be easier to use.
Co-authored-by: Kai Jellinghaus <contact@kaij.tech>
|
Few more small tweaks on the way based on Discord conversations and personal gripes... |
Unfortunately wouldn't be possible as we need to capture the API object and other native handles when creating the backends (won't just be parameterless so we can't use the |
|
@HurricanKai @ThomasMiz are you happy with this? Please approve on GitHub if you are. |
|
Hi @Perksey & @HurricanKai, The more I look into this the more my spidey-sense is tingling. The source generator requirement breaks KISS and produces non-intuitive behaviour for a consumer of the libraries. Further, it produces a default behaviour for the backends we know about, that isn't reflected for those we don't know about (which is bad design). Every time a solution like this has appeared in the past I've eventually realised there was a much simpler approach. I get we don't want a dependence from Input -> Backend, for example, when using VR we won't want the Window backend, etc. The case for why we don't want backends dependent on input is far less clear though. I suspect the main justification is that you might wish to use a separate input system. This is countered by 2 approaches, have the backend implement functionality for input by implementing a thin set of functionality atop of the base classes and interfaces in input, but ensure that initialisation/etc. is optional (classic approach); or have a third package that is dependent on both the Backend and Input (this is overkill). |
|
One way we have countered this in the past is having an interchange type in Silk.NET.Core. For Windowing, this was In Silk.NET 1.X, we did have a package which literally only existed to bond Windowing and Input, but I don't think that was a good solution. 2.X is structured such that each backend has its own package. We could do this again (i.e. EDIT: on the back of this i really don't want to change anything in Windowing, what we have now is really good and if we do package splits we'll have lots of horrible concerns with backend resolution. |
|
I'm not a big fan of using source generators for this either, but it's a bit unclear to me what other options there are? We don't want to use reflection, and we don't want to have the user deal with what backend they use. |
|
I've created this Gist to show how I would build the API, based on our conversation at the last working group about immutability and asynchrony. It compiles but isn't 'tested', it is purely to make some of the ideas more concrete to aid discussion. I've put a basic (not yet thread-safe, but easy to adjust) implementation of a MouseDevice, and a 'Window' based backend which would appear either in the I've illustrated those use-cases in the example code at the start. In essence, using a mouse event can be as simple as: // Get a backend that implements IInputBackend
IInputBackend backend = new Window();
// Create a new context from any number of backend(s)
InputContext context = new InputContext(backend);
// Find a connected mouse
MouseDevice mouseDevice = context.FindDevice<MouseDevice)(true);
// Subscribe to events
mouseDevice.MouseMove +=
e => Console.WriteLine($"Mouse moved {e.Delta.X}, {e.Delta.Y} to {e.Position.X}, {e.Position.Y}");Though the examples, demonstrate more robust connection handling. Alternatively you can just find a connected device and get its current state, each time round the game loop: // Will find the first valid, connected, mouse state each time.
MouseState state = context.GetState<MouseState>();
Console.WriteLine($"Mouse moved to {state.X}, {state.Y}");The idea is that the A device state, at its core, is a snapshot dictionary of string->long. From my own work implementing HID devices, this is a good compromise as it supports just about any real data. Where a floating-point type is needed this can be achieved using blitting or similar. How the long is mapped to the actual value is handled by the specific device class. The beauty of having a common, centralised, state system is that it is significantly easier to monitor all devices concurrently. It is even possible to allow a game developer to bind to an unknown device's inputs by creatively using the friendly names provided. Devices can extend state functionality to make it more intelligible to end-users, which the MouseDevice illustrates. The input device system tracks the addition/removal of state keys, as well as the change in their values. This allows a keyboard device to indicate a key press by adding/removing the state, but may key the modifier key states around permanently; or, as per the example, a mouse can have an X and Y state permanently available for its state. By using the abstract device implementations for common devices (like mouse/keyboard), a backend only has to implement the minimum amount of code to call SetState on its devices whenever a state change occurs. This can happen asynchronously to the input system. The state is only exposed to the end-user as a snapshot. There is no need for code generation, in this model |
|
I really like the idea of the device just holding the state, the backend updating that state as needed and then the user just querying that state. |
|
Windowing and Input I want to keep separate. I will write up the rest of my opinions later. |
|
Worth noting that, at the moment, the design only uses Context as a convenient Backend grouper. You could drop Context entirely and have the In terms of keeping input out of windowing, then the normal approach is the shim NuGet approach. This is what logging libraries usually do (amongst many others). It does create NuGet bloat, but it's not a massive deal. That's still preferable to magic code injection (IMHO) which is non-intuitive to a consumer/contributor. Placing Window logic in Input is also worrisome as it's effectively backwards and makes Window input behave differently to a 3rd-party backend. |
Agree, I think a
Most of my irks were with the implementation rather than the API, so I've ignored that in the name of the proposal.
|
Yeah, this was thrown together to make the API compile and demonstrate the concepts a little more readily. It's not production ready.
By having state represented as a simple Dictionary it is using a simple property-bag model - this is something I've come across repeatedly and it has a number of advantages over using unknowable extensible structs. This allows the code in the abstract device base class to handle state change logic (effectively diff'ing), event propagation and thread-synchronisation logic (not shown in the example), for all backends without backends needing to re-invent the wheel. Further, diffing is faster (you can do it with unknown structs using reflection, but that has a start-up performance at least and requires a lot of code to create compilable lambdas). It allows that without the input engine having any idea what a backend actually does.
The generic event is called for all state changes because we know about all state changes at the base. This is convenient for logging and debugging as it allows you to see changes being raised by devices you're learning about, also useful during backend development, as you can quickly throw something together before adding all the extra event and state wrappers. However, descendent Devices can (and should) add more specialised events that are more relevant, at no additional performance cost (I won't go into it now, but you can reduce the performance impact by only doing stuff when events are listened to, I didn't implement that for clarity). The public event Action<MouseMoveEvent> MouseMove;
public event Action<MouseButtonEvent> ButtonsChanged;This approach gives you the best of both worlds IMHO.
Not quite sure where you're going with that one (look a bit RX like). Under this proposal the backend only needs to override one of the standard abstract devices (like MouseDevice, KeyboardDevice) and implement calls to Another reason for having the base public class BacklitKeyboardDevice : KeyboardDevice
{
...
public event Action<BackLightEvent> BacklightChanged;
public void SetBacklight(Vector3 color) { ... }
}
public class BacklitKeyboardState : KeyboardDeviceState
{
#region State Keys used by Device
public const string StateKey_BacklightColor = "BacklightRGB";
#endregion
public BacklitKeyboardDevice Device { get; init; }
...
#region Mouse State helpers
public Vector3 Color => TryGetValue(StateKey_BacklightColor, out var value) ? new Vector3(/* Convert long */...) : Vector3.Zero;
#endregion
}
public readonly struct BackLightEvent
{
public readonly BacklitKeyboardDevice Device;
public readonly Vector3 OldColor;
public readonly Vector2 NewColor;
...
}The beauty of this is that any code written to expect just a KeyboardDevice will continue to work as normal. And if you have Debug code writing out events from the keyboard you will see state changes referring to
Yeah, that was a toss-up, I made it abstract as it's somewhat limited usefulness at the moment, it merely groups together backends. However, being a backend grouper then it's also hard to see the use case for a backend extending it... |
This is just a struct wrapper around a dictionary though, which I don't think is needed. Because it's a struct, it isn't really any more extensible than if they were regular fields (and I'm not saying make it more extensible, I'm saying we don't need this). Dictionaries will lead user code to have to do roundabout hash table lookup in use, something which is an undesirable expense given this will likely be used on a hot path. The best summary of my train of thought is that I just don't want a bag, I want pigeon holes where everything has its place fixed. The dictionaries make me uncomfortable. For diffing I think we should do this manually or if this proves to be too much of a small make a private (i.e. not seen by the end user) source generator which does this for us. If we really wanted to get performance nuts about this, more theoretical performance is possible by doing it this way if we wanted to use intrinsics in the diffs (but I doubt we'd need to)
This will decrease the number of
Unified mouse events were rejected in the initial drafts of Enhanced Input Events, so I'd rather not do that. It's going out of our way to remove all familiarity for little benefit, and it makes it awkward to use. I'd imagine most of our users don't even care about changing the state, they just want to know when things happen and having dedicated events with the event structs proposed in Enhanced Input Events exposes this in a clean way.
Eeeeeeeehhhhhhhhhhhhhh come back to me.
The idea is that the Input Context subscribes to a specific kind of state change individually, and routes this to the events it exposes. |
|
I think the main thing is the new events are too fluid. I would rather have rigid events with a fluid way of subscribing to them rather than fluid events with a rigid way of subscribing to them. This would what my proposed |
|
I've gone down the rabbit hole before of building type differs. In fact, I've gone back and forth between dictionaries and type members (using reflection or T4 source gen) so many times, I've lost count (check out the horrifying decade long history on the core libraries, which I wrote most of between 2007-2018, for just a few examples of the back and forth). It has therefore been my bitter experience that, as the adage goes, performance is an engineering problem, not a design one. That is you shouldn't optimise it unless you prove you have too. I have used dictionaries on hot paths of very large scale applications, and they are fast (being hashtable lookups). But I accept it involves heap allocations, etc. Either way, using source gen or reflection both require a lot more work, so you really should establish the problem exists before exploring that path. At least that's been my experience. In a similar way, event batching just makes sense with a batch state setter, and though event invocation has improved over the years (compared to the bad ol' days) it's still best minimised. Ultimately, events are multicast delegates and are therefore slower than simple method invocations. In terms of rejecting unified mouse events, I think it's more than that. A mouse, like a keyboard, is capable of reporting multiple buttons being pressed/released at the same time, having them grouped makes it much easier to code for detecting, for example, left & right mouse buttons pressed together; just as you would do with multiple keys (which I know was a criticism levelled against the old API). However, I'm not precious, I wouldn't use the event interface in practice anyway, preferring to grab the state on each game loop, but it's there for those who prevent event-driven programming. But, the more events exposed means the more checks and invocations required every time a state changes, I see that as a bigger overhead than dictionary lookups. I'm really not sure what you're getting at with the Subscribe/Unsubscribe proposal. It looks, exactly like a slower re-implementation of multicast delegates/events by another name? As for adding concrete events (like MouseMove) to the Context, that blocks extensibility and future-proofing as you can't add additional ones in future when implementing a new Device (like the BaclitKeyboard example I gave). I'd try to implement what you're suggesting, but I don't think I've followed you properly, could you work up a practical example? |
Right but I don't think we should allow ourselves to potentially be bottlenecked by a design which, if approved now, we'd be locked into until 2023 when Silk.NET 4.0 releases. By the time we've proven that this is a problem, there's nothing we can do about it because the dictionary stuff is part of the public API. At least if it were an implementation detail we can choose not to use it. Moreover it's our job to think of performance as much as possible, as Silk.NET can never afford to be even remotely a bottleneck.
Not as fast as regular field retrievals.
They're not that much slower on a hot path. Most modern CPUs will cache the VTable resolved function's instructions if called lots (within reason), and this is why our indirect calls don't prove to be that slow in practice either.
Yes it's literally just to get around that we can't do public event Action<T> StateChanged<T>;I agree it's not great, but IInputBackend was never intended for general consumption - InputContext remains the main user-facing API.
Will have a play this afternoon.
Why would this be the case? We want to be able to change the Context class however frequently we want without affecting the backend. |
Agreed, sorry I meant that, for performance-critical scenarios, sometimes we need to prototype options and benchmark prior to settling the design, not that we develop and release a less performant version and wait for the next major milestone. You see this approach a lot in the dotnet team's own approach when debating designs of performance-critical code (I've had to submit BenchmarkDotNet samples, to show relative performance in terms of speed and allocs).
Yes, obviously not! However, the real-world impact still comes down to how often you do those ~5ns dictionary lookups each game loop compared to everything else you do, and what you gain/lose in return.
Agreed, this is the problem with performance during design is that these slower/not slower things are hard to predict the impact of without an actual real-world benchmark. I only said they were slower, not by how much. Indeed, even after years of writing performant code, I get surprised when something I thought would be slower is actually faster, etc. That's particularly true as the compiler team continue to optimise (cf the new record struct performance compared to plain old structs, they add cool functionality and are faster). For example, we used to have to avoid
Ah! 💡 So in my proposal the separation of concerns is this:
In the proposal:
To use the system you add When I say this design is future proof, consider the following example. A 3rd party consumer wants to make use of our library, but has a custom device they want to work with; that device is a gaming keyboard with a backlight. They don't want to re-implement the wheel for gaming keyboards, and in fact, want their gaming keyboard to be useable by existing games with minimal effort. As such, they implement a new backend ( They don't have to worry about the state thread-safety, or change tracking and event propagation, the InputDevice base class has done all that work for them already. Nor do they have to request we add something to Your 'Subscribe''Unsubscribe' model, however, does allow you a way to get close whilst remaining future-proof. If a device, instead of exposing events, calls a generic base method on Based on SOLID, the 'context' should not contain events like However, if you are concerned about, for example, getting all keyboard events from multiple keyboards, then this is where my proposal is that we forget about 'context' and instead think more about device grouping. My current proposal treats context as a device group of all devices provided by a group of backends, that concept can be extended to a group by type (all keyboards from all backends), etc. There are complexities though, about how you group together state (effectively overwriting in specified priority order). But that's a point for a broader discussion and breaks KISS. Usually, a consumer wants to keep separate events from individual devices (e.g. gamepads) not group them all together. As such, the proposed design of finding a connected device instance from a group of backends and then querying it about its state (or subscribing to its events) seems like the right approach. Indeed I have no objection to dropping the |
This enables input to use multiple backends (i.e. so we can have windowing-based input as well as something like OpenXR) and contains other refactorings for 3.0.
Unlike previous 3.0 proposals this proposes only a minor change of the public API.