Saturday, 28 July 2018

Empty Rust File to Game in Nine Days

I've been doing Rust coding for a bit now. Recently that's involved briefly poking at the Core Library (a platform-agnostic, dependency-free library that builds some of the foundations on which the Standard Library are constructed) to get a feel for the language under all the convenience of the library ecosystem (although an impressive number of crates offer a no_std version mainly for use on small embedded platforms or with OS development). I'm taking a break from that level of purity but it inspired me to try writing a game just calling to the basic C APIs exposed in Windows.

So I'm going to do something a bit different for this blog: this post is going to be an incremental post over the next nine days. I'm going to make a very small game for Windows (10 - but hopefully also seamlessly on previous versions as long as they have a working Vulkan driver), avoiding using crates (while noting which ones I'd normally call to when not restricted to doing it myself). I'm not going to rewrite the external signatures of the C APIs I have to call but I'll only be importing the crates that expose those bare APIs.

Day 1

The day of building the basic framework for rendering. Opening with a Win32 surface (normally something you'd grab from eg Winit) and then implementing a basic Vulkan connection (which would normally be done via a host of different safe APIs from Vulkano to Ash, Gfx-rs to Dacite).

The Win32 calls go via Winapi, which is a clean, feature-gated listing of most of the Win32 APIs. The shellscalingapi is a bit lacking as it doesn't expose all the different generations of the Windows HiDPI API (and Rust programs don't include a manifest by default so you typically declare your program DPI-aware programatically) which means you have to declare a few bindings yourself to support previous editions of Windows. But generally it makes calling into Win32's C API as quick as if you were writing a native C program including the required headers. You could probably generate it yourself via Bindgen but the organisation here is good and it's already been tested for potential edge cases.

Vulkano exposes the underlying C binds via the Vk-sys crate. It has no dependencies (so it's what we want: just something to avoid having to write the signatures ourselves without obfuscating anything going on) and while it's not updated to the latest version of Vulkan (1.1), we're only doing a small project here (so it shouldn't matter at all). The function pointers are all grabbed via a macro, which is a bit cleaner than my previous C code that called vkGetInstanceProcAddr individually whenever a new address was required (to be cached). Of course, other areas are down to just the barest API which means looking up things like the version macro.

So at the end of day 1, we've got a basic triangle on the screen working (with a 40kB compressed executable, most of which is Rust runtime/stdlib as Rust libraries default to static linking).

Day 2

When you spend all of the second day refactoring the code you wrote on the first day to be less of a monolithic C initialisation march (I was basically porting my previous Vulkan code that was written in C during the first year of Vulkan 1.0). As I decided that I was going to make this public, it was worth at least doing a basic job of cleaning it up. Plus I needed to catch a bug that had managed to slip into my initialisation code (Rust is maybe not a perfect match for a lot of playing with C APIs on Windows due to the various juggling of strings - you've got i8 C chars, Windows wchar 16-bit Unicode, and all of Rust generally prefers native UTF-8 because that's what the standard library uses) so today the Vulkan validation layers are actually working as expected rather than sometimes spawning an error from the depths of the kernel.

Days 3 & 4

Some days you just have to take a break (at least from one type of activity) and make time for other tasks. Well who really needs all 9 days to make something from scratch without using any libraries? Oh, but have a screenshot of how little time I've had in the last two days:

Day 5

Today generally involved poking at a few bugs until satisfied they were actually fixed and some minor refactoring of code that needs to work differently before we're done. I'm starting to see the issue with doing this live rather than pre-recording thoughts as each day doesn't radically change anything during the week so this might work better as a weekly log (except this jam is only 9 days long).

The game no longer has issues on Intel when minimising (and I think that block of code is basically solid now in dealing with all cases), but I'm still not happy with the behaviour when dragging the window resize. The wnd_proc resize messages seem to be getting lost/never arriving when being rebroadcast to the main message processing queue, probably because the mouse isn't over the window so the main thread that calls GetMessage isn't active. But timers and so on don't seem to be unlocking an easy fix so I'll probably drop it (ie ignore the game rendering pausing during window resize and ugly unredrawn extends) in the interests of being over 50% through the schedule. Even if it's extremely ugly to only redraw the contents of the window at the end of a resize drag.

One fun thing about using mailbox present mode with a window while the Vulkan debug interface is up (including VK_LAYER_LUNARG_monitor) for these trivial scenes is you can see your mouse polling rate in action. Every time the mouse moves over the window area it allows the renderer to refresh and so the FPS counter shows you how many times your mouse has been detected moving - showing that my Logitech mouse does genuinely use that 1kHz polling rate when moving at normal speeds over the desk.

Day 6 & 7

RIP plan to increment this blog post with a daily cadence. Days 8 & 9 are another weekend so that's going to be the blast of actually making a game on top of this extremely minimal rendering engine.

So that was... a fun careless mistake (tying the render trigger each iteration to processing the message queue - noted as a fun thing with the mouse above without really thinking about it more). When updating the code to not actually wait on the message queue (necessary for making an actual game where time moves forward when there is no input; not so important for just making a window that shows your renderer is working and generating frames on request) then obviously that goes away. The standard swap chain sanity checking code (and WM_SIZE messages being resent from wnd_proc) during a resize is still doing nothing to remove those ugly redraw artefacts so at least this foolishness wasn't also evident in that issue.

Already I've caught a few bad practices I was engaging in with Rust and corrected them in my coding, which is where these sort of constrained projects with no helpers often push you (and why I'll always recommend such exercises). I finished doing the core rendering structure before the weekend so I now have something (extremely basic) to build on. UI/text, 3D: it's all here (probably not shipping for the jam deadline: audio - doing that to a raw OS API is not something I've tried before in any language and probably won't be fast) and I can remove the "7000th Anniversary" misdirect used above. Welcome to 1978, the -30th anniversary of the start of GiantBomb. *loads up vector graphics*

I said up at the top that I'd be listing off some crates that I'm only avoiding because I'm playing with bare APIs and doing everything myself as an experiment here. I then have basically not told you about any crates since day 1. So here's something that I wish was in the core Rust type system because we're now pushing data to a GPU: binary16/f16. Also offsetof is a pretty standard feature Rust leaves to you writing your own macro or using a crate implementation.

Day 8

Looking at the progress made so far, it seems unlikely that I'd have what I consider a valuable project completed by the jam deadline so rather than burning a weekend making a mess of the code I've worked on so far (doing everything to be quick rather than right), I'm going to back-burner it and use it as a jumping off point for the next time I want to build something like this. It's now gotten to the point where I'm happy that anything I can do in C, Rust allows me to do too. Small projects coding directly to a C API feel just as native as projects constructed on top of Rust libraries that insulate you from the OS or driver layers.

I didn't have time to integrate some of the more exciting areas of Rust (fearless concurrency) into this project but at every stage the compiler has provided excellent guidance and I'd expect it to continue to do so in future expansions. I will probably try threading everything as the next expansion of this project (as it remains one of the core reasons I think C doesn't have a great future) in a few weeks. Give myself some time to switch gears.

Vulkan remains verbose but also comfortably comprehensible. The same as how this project helped cut away some of the distance from code to action by avoiding libraries, so a verbose API helps link what I type and what's actually going on (or at least a closer approximation of it than other APIs I've used).

I am definitely going to permanently archive my old C projects, including the various renderers I've written over the year. This few days of full work (spread over about a week of time) has shown that I should be able to get anything there onto a new language with better guarantees about code correctness and a lot more assistance in developing correct threaded code. I may even consider keeping the raw API stuff here, look through the Rust libraries so any gotchas they catch that I've missed can be added. Not sure if I prefer access to just the complete C API itself or more of a wrapper. I suspect for correctness (via many people looking for bugs) and development speed that it's the smarter move to stick with a library but also there is some cruft that adds (especially coming from idly considering the demoscene consideration of total executable/file size). I suspect that reaching the economy of C will involve doing a project with only Rust Core + libc but at least this library-less project (ie adding in Rust Std) got close enough that I can see there (10kB to get ready for actual game code) from here (40kB to get ready for actual game code).

No comments:

Post a Comment