Cross-platform API design

Started by
9 comments, last by L. Spiro 8 years, 1 month ago

I'm currently trying to rewrite a small engine using a style which is more towards C than C++, and at the same time I'm trying to figure out a way to keep the platform specific code separated from the platform-independent code.

The basic idea is to have a folder structure with one folder containing all the generic code and one folder per platform where all the platform-dependent code will live. Each platform will then include the generic code where needed. Super simple structure and it's very easy to see which files belong to which platform. The whole point of this is to get rid of randomly placed #ifdef PLATFORMX spread throughout the code.

Do you have any tips for how this can be done in a nice and clean way?

So far I've got these ideas:

The platform-independent code can define and use a basic struct which contains data that must be present on all platforms. For a sprite object it could be stuff like position, rotation, scale, list of animations etc.

The struct could then also define a void* platformData at the end, where the platform layer can define the data it needs (file handles, render info, etc). It could also work with platform-specific sub-structs, where Win32Sprite contains a Sprite object at the top plus the platform-dependent info. So casting a Win32Sprite to Sprite would be fine since both have the same data layout at the start.

For a potential Sprite::render() function, it could just push the generic sprite info (material, position, etc) onto a render queue which can then be processed and rendered to screen on the platform-dependent side however it sees fit.

The loading code would however need to be implemented per platform, so there's really no way to implement a Sprite::load() function without it being platform-dependent. For these kinds of functions I really have no idea how to properly do it. One way would be to define a function in the platform-independent code that must be implemented in the platform-specific code, but in that case it would be nice if those "required functions" could be kept in the same header so it's easy to see what a platform layer is required to implement.

If you have any tips or links to articles, I would greatly appreciate it!

@mikaelbauer

Super Sportmatchen - A retro multiplayer competitive sports game project!

Advertisement

I want to use C++ as if it's C, but I'd like to to reinvent C++ again. Any suggestions?

You're on the right track with putting the platform-specific source in a separate storage area ("folder"). To take advantage of that, the method that was developed in your granparents' day was to define a platform-independent API (in the C language, this is a header file) and provide platform-specific implementations called "libraries." Each source storage area contains code that will generate a platform-specific library that implements the defined interface, no need for #ifdefs. That way, your separate source area gets built into a separate binary module that can be tested separately and mocked easily, compiled only where appropriate, and linked in to your final program.

You can handle the platform-specific stuff in your platform-neutral code by using opaque objects. That is, you pass them around by pointer, and use API calls to construct and destroy the objects and to perform operations on those objects. For a classic example of using platform-independent opaque objects in the C programming language you need look no farther than the C standard library's stdio module and the FILE type.

You can use struct composition in C to reimplement C++-style inheritance by placing the "base" struct at the beginning of the "derived" struct. The language does guarantee the order of member data, so a pointer to the "derived" structs will be a pointer to the "base" struct. An excellent example of using the C programming language to reinvent C++ classes is the gobject library.

Good luck and enjoy your adventure.

Stephen M. Webb
Professional Free Software Developer

A typical method I use leverages the compiler include paths. Here's an example:


myProject
  include
    core <- platform agnostic headers, <core/whatever.hpp> for instance.
    windows <- platform specific headers for windows.
      core
    osx <- same, just for Mac.

When I build the solution, makefile or whatever (I use CMake most of the time) I set the include path to <include> for all the generic code and then add an include path for <include/windows>. Now, when I type in "#include <core/Platform.hpp>" that comes out of the windows specific directory. I have no need of ifdef/else/endif pragma's and things are relatively clean. A nice thing about this (especially using generators like CMake) is that you can use the same style for things such as target processors, if SIMD is enabled/disabled etc. Of course you eventually end up with 4+ different paths to include the single library but I have found that preferable to most other solutions I've tried. Also keep in mind that the same style works at the source level.

Hope this helps.

I usually build platform code as separate library ( dynamic or static, depends on the platform ). It's because for example on Linux I have more than one platform backend ( in terms of opening window, graphics API etc. - like now I support OpenGL and Vulkan ) ). I do it this way, because on the single platform I want to be able to switch platform backend in the runtime. So platform code is built separately. I use CMake to organize project structure and thanks to "subdirectories" I can build platform code pretty much separate from the rest of the engine. I recommend CMake because it allows you keep your code IDE-independent. Hence, nothing stands in the way to use the same codebase with generated project for Xcode or Visual Studio. I also separate public and internal headers, and as it was mentioned above, making the platform 'device' object opaque is the right way. If I build just engine code, it uses headers, but it doesn't build the platform module. It will load one dynamically later ( exception may be static linking if dynamic isn't possible for some reason ). What you must ensure is the binary compatibility between the modules. Engine itself doesn't care with what platform it works. It will load specific platform driver, initialize it and just use it. For me it works like a charm and good thing is that each platform module may be completely separate repository, because all I need to build it are public headers of particular code level ( I keep my engine "layered" ).

Thanks a lot for the tips and feedback! Sounds like my ideas are decent enough to pursue, plus I've gotten some new insights from your answers. Much appreciated!

@Bregma: I really enjoy your passive aggressive tone.

@mikaelbauer

Super Sportmatchen - A retro multiplayer competitive sports game project!


The struct could then also define a void* platformData at the end, where the platform layer can define the data it needs (file handles, render info, etc). It could also work with platform-specific sub-structs, where Win32Sprite contains a Sprite object at the top plus the platform-dependent info. So casting a Win32Sprite to Sprite would be fine since both have the same data layout at the start.

Maybe you were just throwing that out there, but sprite code shouldn't have to change across platform or graphics API. Only the very low level texture manipulation and drawing will be different, and your Sprite class should be built on top of a cross-platform wrapping of those behaviors. Also note that when it comes to graphics, especially 2D graphics, many OSes have many options - for example, on Windows, you can use GDI (unless you want rotation), DirectDraw (way old), Direct3D, or OpenGL. You could even implement a reference "software renderer" if you wanted.

SDL2 does what you seem to be wanting to do, only in C. You might take some hints by glancing at their API structure, if not learning a thing or two by actually reading the source code. Or, you know, you could save yourself ALOT of time and effort by building your API on top of SDL2.

Hey nfries88, thanks for your reply! First of all, yeah I'm currently basing my Windows/Mac/Linux version on SDL2, which is great. I should spend some time with browsing their source to get more ideas on how to handle different platforms, that's a great idea!

My initial idea with the sprite code being different between platforms would only be for the parts you mention, as in the loading and drawing code (etc). My initial idea was that I would create an array of generic Sprite objects with a pointer to some platform data and the platform data could contain OS/renderer specific info (texture ids, file handles, SDL_Surface* etc). But it feels a bit weird to do it that way and it also feels insufficient in terms of the code having to jump between the generic sprite info and the platform info.

My current plan is to, as before, let the generic code just work with the generic Sprite struct. The platform would though instead make a PlatformSprite struct which contains a Sprite struct and whatever platform data is needed. When the generic game layer asks for a Sprite object, it would be initialized as a PlatformSprite and then given a pointer to the Sprite part of it. The generic game layer is pretty much only done in Lua anyway and Sprites are passed around as light userdata (pointers). So that seems like a pretty good match.

@mikaelbauer

Super Sportmatchen - A retro multiplayer competitive sports game project!

I guess the real question is if by "sprite" do you mean a whole texture, or a rectangular region of a texture (IE, an element in a spritesheet). For 2D games on modern GPUs, using spritesheets are far more efficient than having each individual sprite on its own texture, so it's somewhat important you facilitate spritesheets. For example, you can have an Image struct wrapping the OS-specific parts of texture management:


typedef struct {
   void * OS_data;
   int w;
   int h;
   int format; /* or a format struct, or what-have-you */
} BauerImage;

Then you can have a spritesheet struct, defining the dimensions of the sprites and any additional per-sheet settings (IE colorkey), and referencing the image:


typedef struct{
   BauerImage *image;
   int sprw;
   int sprh;
   uint32_t colorkey;
} BauerSpriteSheet;

Then you can have a sprite struct, which just identifies which sprite you're refering to in the sprite sheet (by index or by region):


typedef struct{
   BauerSpriteSheet *sheet;
   int id; /* see function below for how to use, or you could precalculate the region */
} BauerSprite;
void BauerGetSpriteRect(SDL_Rect *pRect, BauerSprite *spr)
{
    int numx = spr->sheet->image->w / spr->sheet->sprw;
    int numy = spr->sheet->image->h / spr->sheet->sprh;
    if(id > numx * numy) *pRect = { 0, 0, 0, 0 };
    else
    {
         int y = spr->id / numx;
         int x = spr->id % numx;
         *pRect.x = x * spr->sheet->sprw;
         *pRect.y = y * spr->sheet->sprh;
         *pRect.w = spr->sheet->sprw;
         *pRect.h = spr->sheet->sprh;
    }

Yeah, definitely going with sprite sheets! There will be some data for the available animations in the sprite sheet and the per-frame info will contain the row/column index of the subrect. So essentially in my world a Sprite is a usually a sprite sheet containing an array of available animations where each animation contains an array of available frames.

@mikaelbauer

Super Sportmatchen - A retro multiplayer competitive sports game project!

TBH that might be better as a subclass of sprite sheet, since tilesets will rarely have animation.

This topic is closed to new replies.

Advertisement