Cross-platform abstractions require per-platform abstractions
There exists this awesome, aged and battle-hardened, cross-platform IO and windowing library named SDL. Originally developed to facilitate porting games across multiple operating systems, it's now been established as a good interfacing library in general. It saves a lot of fiddling with OS-specific code in general.
Since I'm currently learning to work with Vulkan, I needed a windowing library for displaying the results. In the past I've done some work with SDL2 already, so it was a natural choice. One major difference from previous work: I was writing Rust now, thus using the SDL2-rs bindings. While there are alternatives like GLFW and Winit available for Rust, my experience with other libraries than SDL2 is that they don't handle edge cases quite as well (for instance, multi-monitor Linux desktops).
Interfacing Vulkan
After getting a window displayed, which is a breeze, the work continued by implementing the Vulkan boiler-plate with the low-level bindings from the ash crate for Rust. Soon I encountered a point of friction between low-level Vulkan code and SDL2 (in Rust). During initialization Vulkan needs a handle to the OS's window surface. This surface normally needs to be created manually with the right window type for your OS target. However, since SDL2 window instance encapsulates the OS's handles and abstracts them away (remember, SDL2 enables to "write once, run everywhere"). This meant I couldn't access the necessary information. The tutorial I'm using (vulkan-tutorial.com with the vulkan-tutorial-rust examples) uses Winit and doesn't function comparable at all. Great.
The good news was that, of course, SDL2 implements it's own function to abstract all of this OS specific code away using the vulkan_create_surface()
method. Great! But how do we use this with the ash
library? Ash takes advantage of Rust's great type system by giving all handles it's own type. No accidental switch-ups. But the handle SDL2's aforementioned method returns has the type of u64
. Under the hood those types defined by ash are nothing but u64
's and represent exactly the same thing, but here's where the type checker wouldn't allow me to do what was theoretically correct. And casting non-primitives isn't a thing in Rust.
After trying to get around the type system for a while I surrendered and went on to seek help on Reddit. Luckily the always-helpful Rust community solved the issue for me pretty quickly: ash implements a from_raw(_: u64)
method, but you have to bring ash::Handle
into scope first. 🤦
Platform specific shenanigans in SDL2-rs
While my issue was resolved with that, I didn't sit idle while waiting for an answer. In meanwhile I had found out about the raw-window-handle crate. This crate is an initiative of the Rust "Game development working group". Apparently dealing with window handles is a common issue, thus here they provide a common interface of retrieving them across IO-libraries. And the best part: somebody had implemented it in SDL2-rs
roughly two weeks ago 🎊. So here I was able to utilize this library and get around my problem. Or so I thought.
The implementation had a bug. Not a complex bug, as we'll see, but a simple bug. One would think that an aged popular library with the sole purpose of enabling simple cross-platform IO abstraction would behave identical of different operating systems (as long as the window manager isn't getting in your wheels, of course). That has been my experience with SDL2 so far. But apparently not.
The contributed code always gave me an "Couldn't get SDL window info: Application not compiled
with SDL 2.0" error when the underlying binding GetWindowWMInfo()
was called. After doing some digging through various issues with the same error message, I saw someone comment that SDL_GetVersion()
always has to be called before it. Et voilà, problem solved. Why hadn't the original code-owner noticed this?
So, let's create a pull request!
The very helpful maintainer of the SDL2-rs
project gave quick response:
Which was a good point. You want to keep raw external library calls concentrated to small area so that with an API change of the underlying library the cost of porting is smaller. But the reason I re-defined the external call to SDL_GetVersion()
was because the raw-window-handle
module re-implements are few types from the crate. But why did it?
After tagging the original code-owner he weighed in:
Soo... the extern "C"
calls aren't cross-platform. You have to use bindgen on Mac OS to be able to call the underlying C library. Jolly. On Mac the call apparently doesn't error when not polling SDL's version ahead of it.
Luckily generating those bindings is very easy, and even done by SDL2-rs
when defining the feature "use-bindgen" in the cargo.toml project file. But those bindings differ for the GetWindowWMInfo()
function. That's why the code-owner had to redefine the struct in our new module (apparently it's just never called in any of the others).
After this I implemented a conversion to the crate's SDL_version
with Into
so we could get rid of the re-implementation of the extern call. The maintainer, of course, thought that implementing a local definition of SDL_version
and a conversion to the crate's variant was a lot of redundant logic. The local redefinition only existed so that we could #[derive(Default)]
on it. So I eventually opted by initializing the SDL_SysWMinfo
struct with mem::zeroed()
. Everything seems to have turned out fine that way.
In meanwhile I was already helped by Reddit, so I haven't been using raw-window-handle
as of yet.
Conclusion
Even though the library you're writing bindings against does a great job abstracting any variation in system behavior, you'll still have to keep your eye out for discrepancies. In essence you'll have to re-do part of the work of the underlying library when creating bindings. Especially when the build process differs for the supported platforms.
I'd still very much recommend using SDL-rs
. In the end such problems will be handled by the library, so that you can focus on your business-logic.