Minimal cross-platform graphics
I grew up with BASIC and Turbo Pascal. My first packages have been quite simple graphical video games. I nonetheless miss the period of BGI and BASIC’s portray instructions. These graphics have been neither stunning nor quick, however they have been easy. Easy sufficient for a child with solely elemental geometry data to construct one thing thrilling.
Even immediately, every time I need to prototype or visualise one thing, to experiment with generative artwork or to write down a toy graphical app – I really feel nostalgic for the previous days.
Positive, there may be SDL2 and Qt and JavaScript Canvas and Löve and plenty of others. However how onerous may or not it’s to construct our personal cross-platform graphical library from scratch?
Our aim right here is to have a framebuffer for a single window that may run and look the identical on Linux, Home windows and macOS. Good previous C would the language of option to hold the retro-computing ambiance of the story.
TLDR: the tip result’s accessible as a single-header library on Github: https://github.com/zserge/fenster.
Linux
From my previous expertise with webview and lorca, Linux is the simplest platform to develop for. Or possibly I’m simply biased. Of all of the graphical choices that Linux gives we’ll select X11 as the inspiration to our window, because it’s the bottom widespread denominator, so to say.
All of it begins with opening the show connection. X server is an precise community server, so all of the APIs are particular packets despatched over the X server connection. As soon as the connection is established – we must always select the display screen and create a window on that display screen. Thankfully, X11 comes with quite simple and handy APIs for all of it. Then we enter the occasion loop the place we ballot for incoming occasions and may deal with them. As soon as we’re about to exit the app – we shut the show connection.
// cc essential.c -lX11 && ./a.out
#embody <X11/Xlib.h>
int essential() {
Show *dpy = XOpenDisplay(NULL);
int scr = DefaultScreen(dpy);
Window wnd = XCreateSimpleWindow(dpy, RootWindow(dpy, scr), 0, 0, 320, 240, 0, BlackPixel(dpy, scr), WhitePixel(dpy, scr));
XStoreName(dpy, wnd, "Whats up, X11");
XSelectInput(dpy, wnd, ExposureMask | KeyPressMask);
XMapWindow(dpy, wnd);
for (;;) {
XEvent e;
XNextEvent(dpy, &e);
// deal with occasions right here
}
return XCloseDisplay(dpy);
}
In solely 15 traces of code we obtained a working window with the given dimension 320×240 and a title!
macOS
Whereas X11 apps can run on macOS utilizing XQuartz, it’s nonetheless higher to help native Cocoa apps. We received’t be utilizing Swift or XCode and can attempt to solely depend on naked bones applied sciences to stay moveable. Let’s begin with Goal-C, one thing like this would possibly work:
// cc -x objective-c essential.m -framework Cocoa && ./a.out
#import <Cocoa/Cocoa.h>
int essential() {
[NSApplication sharedApplication];
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
NSWindow *wnd =
[[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 320, 240)
styleMask:NSTitledWindowMask
backing:NSBackingStoreBuffered
defer:NO];
[wnd setTitle:@"Hello, Cocoa"];
[wnd makeKeyAndOrderFront:nil];
[NSApp activateIgnoringOtherApps:YES];
for (;;) {
NSEvent *occasion = [NSApp nextEventMatchingMask:NSAnyEventMask
untilDate:[NSDate distantPast]
inMode:NSDefaultRunLoopMode
dequeue:YES];
// deal with occasions right here
[NSApp sendEvent:event];
}
[wnd close];
return 0;
}
Slighly extra verbose however nonetheless very compact and follows the identical logic: begin an app, open a window, run occasion loop, shut the window.
Nevertheless it’s Goal-C and I promised you simply C…
macOS + C
Thankfully, macOS comes with Goal-C runtime library that permits calling Goal-C constructs from C utilizing one thing like a mirrored image layer:
// For instance this:
[NSApplication sharedApplication];
// Turns into:
objc_msgSend(objc_getClass("NSApplication"), sel_getUid("sharedApplication"));
However now it’s barely readable. Think about rewriting initWithContextRect
like this. Additionally, if you happen to experiment extra with it you’ll uncover that objc_msgSend
ought to be type-casted to an accurate signature earlier than every name to keep away from crashes.
However what if we give you some intelligent preprocessor macros to attain a terser syntax?
#embody <objc/NSObjCRuntime.h>
#embody <objc/objc-runtime.h>
#outline msg(r, o, s) ((r(*)(id, SEL))objc_msgSend)(o, sel_getUid(s))
#outline msg1(r, o, s, A, a)
((r(*)(id, SEL, A))objc_msgSend)(o, sel_getUid(s), a)
#outline msg2(r, o, s, A, a, B, b)
((r(*)(id, SEL, A, B))objc_msgSend)(o, sel_getUid(s), a, b)
#outline msg3(r, o, s, A, a, B, b, C, c)
((r(*)(id, SEL, A, B, C))objc_msgSend)(o, sel_getUid(s), a, b, c)
#outline msg4(r, o, s, A, a, B, b, C, c, D, d)
((r(*)(id, SEL, A, B, C, D))objc_msgSend)(o, sel_getUid(s), a, b, c, d)
#outline cls(x) ((id)objc_getClass(x))
Right here msg
is a message (assume: technique name) with no arguments. The return kind ought to be specified, the receiver of the message and the message title itself. Equally msg1
, msg2
, msg3
and msg4
are helpers for messages with one and extra parameters. For every parameter a sort ought to be offered in addition to its worth. Lastly, cls
is only a helper for objc_getClass
.
Utilizing these macros our minimal Cocoa window will be rewritten in customary C as follows:
// cc essential.c -framework Cocoa
...
extern id const NSDefaultRunLoopMode;
extern id const NSApp;
int essential() {
msg(id, cls("NSApplication"), "sharedApplication");
msg1(void, NSApp, "setActivationPolicy:", NSInteger, 0);
id wnd =
msg4(id, msg(id, cls("NSWindow"), "alloc"),
"initWithContentRect:styleMask:backing:defer:", CGRect,
CGRectMake(0, 0, 320, 240), NSUInteger, 3, NSUInteger, 2, BOOL, NO);
id title = msg1(id, cls("NSString"), "stringWithUTF8String:", const char *,
"Whats up, Cocoa");
msg1(void, wnd, "setTitle:", id, title);
msg1(void, wnd, "makeKeyAndOrderFront:", id, nil);
msg(void, wnd, "middle");
msg1(void, NSApp, "activateIgnoringOtherApps:", BOOL, YES);
for (;;) {
id ev = msg4(id, NSApp,
"nextEventMatchingMask:untilDate:inMode:dequeue:", NSUInteger,
NSUIntegerMax, id, NULL, id, NSDefaultRunLoopMode, BOOL, YES);
msg1(void, NSApp, "sendEvent:", id, ev);
}
msg(void, wnd, "shut");
return 0;
}
Now it’s a superbly legitimate C code that does precisely the identical because the Goal-C code above. Furthermore, utilizing this method we are able to create new courses dynamically, connect strategies to them, create delegates and do way more. However for now our aim is achieved – we’ve made an empty macOS window with a title.
home windows
WinAPI was historically designed for use from C/C++, and Home windows is a graphical desktop OS, so making a window on Home windows ought to be fairly simple (though tautological).
#embody <home windows.h>
LRESULT CALLBACK wndproc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
swap (msg) {
// deal with occasions
case WM_CLOSE:
DestroyWindow(hwnd);
break;
case WM_DESTROY:
PostQuitMessage(0);
break;
default:
return DefWindowProc(hwnd, msg, wParam, lParam);
}
return 0;
}
int WINAPI WinMain(HINSTANCE occasion, HINSTANCE prevInstance, LPSTR pCmdLine,
int nCmdShow) {
MSG msg;
HINSTANCE hInstance = GetModuleHandle(NULL);
WNDCLASSEX wc = {0};
wc.cbSize = sizeof(WNDCLASSEX);
wc.fashion = CS_VREDRAW | CS_HREDRAW;
wc.lpfnWndProc = wndproc;
wc.hInstance = hInstance;
wc.lpszClassName = "demo";
RegisterClassEx(&wc);
HWND hwnd = CreateWindowEx(WS_EX_CLIENTEDGE, "demo", "Whats up, WInAPI",
WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT,
320, 240, NULL, NULL, hInstance, NULL);
ShowWindow(hwnd, SW_NORMAL);
UpdateWindow(hwnd);
whereas (GetMessage(&msg, NULL, 0, 0)) {
if (msg.message == WM_QUIT)
return 0;
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
WinAPI model requires a separate callback to deal with all window occasions, not like the earlier two platforms, the place occasions may very well be dealt with contained in the loop physique. Additionally it requires to register a window class earlier than making a window. However apart from that it’s somewhat trivial: open a window, run occasion loop, deal with occasions, exit when the window is closed.
Now that we’re finished with window initialisation and the primary loop for all three widespread desktop platforms – we are able to transfer on to truly drawing one thing inside these home windows.
Graphics
We received’t do any fancy graphics, we received’t trouble with GPUs and OpenGL. We’ll goal for a quite simple framebuffer view. Sometimes, a framebuffer is a piece of RAM that incorporates a bitmap and altering that bitmap impacts the video show.
In our case we are going to create an array of 32-bit integers, the place each integer controls a single pixel of the window. Altering the worth of an integer would change the colour of a single pixel. Extra superior algorithms may very well be used to attract spires, traces, rectangles, circles, arc and so on. However we’ll come again to it later. Now let’s draw some pixels.
X11
In our X11 window a framebuffer will be carried out utilizing a graphical context (GC) and a picture:
Show *dpy = ...
Window *wnd = ...
uint32_t buf[320*240];
GC gc = XCreateGC(dpy, wnd, 0, 0);
XImage *img = XCreateImage(dpy, DefaultVisual(dpy, 0), 24, ZPixmap, 0, (char *)buf, 320, 240, 32, 0);
// later in a loop
XPutImage(dpy, wnd, gc, img, 0, 0, 0, 0, 320, 240);
XFlush(dpy);
Picture is backed by an array, each time we modify the array – we must always name XPutImage
and flush the connection to redraw the window. Writing random integers to the array would create vibrant white noise. Writing 0xff0000 would paint it crimson. Now let’s attempt reproduce it on macOS.
CoreGraphics
We are able to hold utilizing ObjC runtime to create a customized NSView
class and override its drawRect
technique. Then in a loop we would want to invalidate the view, it ought to set off drawRect
and we’re finished.
static void draw_rect(id v, SEL s, CGRect r) kCGBitmapByteOrder32Little,
supplier, NULL, false, kCGRenderingIntentDefault);
CGColorSpaceRelease(area);
CGDataProviderRelease(supplier);
CGContextDrawImage(context, CGRectMake(0, 0, width, peak), img);
CGImageRelease(img);
// Create view class
Class c = objc_allocateClassPair((Class)cls("NSView"), "FensterView", 0);
class_addMethod(c, sel_getUid("drawRect:"), (IMP)draw_rect, "i@:@@");
objc_registerClassPair(c);
// Create view occasion and add it to the window
id v = msg(id, msg(id, (id)c, "alloc"), "init");
msg1(void, wnd, "setContentView:", id, v);
// Later in a loop:
msg1(void, v, "setNeedsDisplay:", BOOL, YES);
If wanted we are able to override extra strategies and create a customized delegate class for the window to deal with the shut button correctly and do different good issues.
HBITMAP
On Home windows we don’t want a separate baby view, however we’ve to deal with WM_PAINT
message as an alternative. Inside we might do issues precisely like we did on macOS and Linux – we create an HBITMAP
, configure details about its pixel format, and ship framebuffer information to the canvas. One thing like this:
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
HDC memdc = CreateCompatibleDC(hdc);
HBITMAP hbmp = CreateCompatibleBitmap(hdc, width, peak);
HBITMAP oldbmp = SelectObject(memdc, hbmp);
BITMAPINFO bi = {{sizeof(bi), width, -peak, 1, 32, BI_BITFIELDS}};
bi.bmiColors[0].rgbRed = bi.bmiColors[1].rgbGreen = bi.bmiColors[2].rgbBlue = 0xff;
SetDIBitsToDevice(memdc, 0, 0, width, peak, 0, 0, 0, peak, buf, (BITMAPINFO *)&bi, DIB_RGB_COLORS);
BitBlt(hdc, 0, 0, width, peak, memdc, 0, 0, SRCCOPY);
SelectObject(memdc, oldbmp);
DeleteObject(hbmp);
DeleteDC(memdc);
EndPaint(hwnd, &ps);
Right here within the BITMAPINFO initialiser -height
is just not a typo. Based on MSDN unfavorable peak values for uncompressed bitmaps imply that the bitmap is stuffed up from the highest to backside (not like BMP format that goes from the underside to the highest).
Person enter
Now that we obtained an app window and a framebuffer working – we are able to try to deal with some person enter, reminiscent of keyboard or mouse. The excellent news is that we have already got an occasion loop/callback, so all we want is to increase that with a couple of extra “case” statements inside a swap.
For mouse occasions we’ll solely give attention to easy issues, reminiscent of motion (X/Y coordinate of the mouse ought to be reported) and the left button click on. With the presence of contact screens and laptops it’s onerous to discover a center mouse button or a horizontal scrolling wheel, anyway.
For keyboard we will likely be all in favour of key presses and releases, in addition to some modifier key standing. At the least we must always deal with Ctrl and Shift.
person enter on Linux
On Linux we first want to regulate our window initialiser to help all of the required occasion sorts:
XSelectInput(dpy, >w, ExposureMask | KeyPressMask | KeyReleaseMask | ButtonPressMask | ButtonReleaseMask | PointerMotionMask);
Within the occasion loop we are able to deal with them and extract info from the occasion construction:
swap (ev.kind) {
case ButtonPress:
case ButtonRelease:
mouse_btn = (ev.kind == ButtonPress); /* is mouse pressed? */
break;
case MotionNotify:
mouse_x = ev.xmotion.x; /* learn mouse X/Y */
mouse_y = ev.xmotion.y;
break;
case KeyPress:
case KeyRelease: {
int okay = XkbKeycodeToKeysym(dpy, ev.xkey.keycode, 0, 0);
for (unsigned int i = 0; i < 124; i += 2) {
/* Map XKB KeySym to our customized key code worth */
if (FENSTER_KEYCODES[i] == okay) {
keys[FENSTER_KEYCODES[i + 1]] = (ev.kind == KeyPress);
break;
}
}
/* learn keyboard modifiers */
int m = ev.xkey.state;
kbd_mod = (!!(m & ControlMask)) | (!!(m & ShiftMask) << 1) |
(!!(m & Mod1Mask) << 2) | (!!(m & Mod4Mask) << 3);
} break;
That is the place the ugly half begins. Mouse dealing with is easy. However for keyboard each platform has its personal definitions of “key codes”. To stay cross-platform we must map platform-specific keycodes into some widespread format.
Often, there are a number of approaches how one can decide that widespread format. Some select the “major” platform and convert key codes from the others, some select HID standard (which is rather well outlined and chic). I made a decision to limit key codes to a small minimal subset, and solely use 7-bit ASCII for alphanumeric keys or symbols reminiscent of Enter, Backspace, Tab and so on. I utterly ignored CapsLock and different not-so-common keys. However a minimum of we will have a key code that may be simply printed to see which key it belongs to. For arrows I made an exception and mapped codes 17..20 to them (DC1..DC4 in ASCII set).
I wasn’t certain if X11 keysym definitions are secure constants on all of the programs that help X11, so I put them in an array the place ASCII key code follows the X11 keysym, so {that a} easy for-loop may map one to the opposite:
static int FENSTER_KEYCODES[124] = {XK_BackSpace,8,XK_Delete,127,XK_Down,18,XK_End,5,XK_Escape,27,XK_Home,2,XK_Insert,26,XK_Left,20,XK_Page_Down,4,XK_Page_Up,3,XK_Return,10,XK_Right,19,XK_Tab,9,XK_Up,17,XK_apostrophe,39,XK_backslash,92,XK_bracketleft,91,XK_bracketright,93,XK_comma,44,XK_equal,61,XK_grave,96,XK_minus,45,XK_period,46,XK_semicolon,59,XK_slash,47,XK_space,32,XK_a,65,XK_b,66,XK_c,67,XK_d,68,XK_e,69,XK_f,70,XK_g,71,XK_h,72,XK_i,73,XK_j,74,XK_k,75,XK_l,76,XK_m,77,XK_n,78,XK_o,79,XK_p,80,XK_q,81,XK_r,82,XK_s,83,XK_t,84,XK_u,85,XK_v,86,XK_w,87,XK_x,88,XK_y,89,XK_z,90,XK_0,48,XK_1,49,XK_2,50,XK_3,51,XK_4,52,XK_5,53,XK_6,54,XK_7,55,XK_8,56,XK_9,57};
person enter on macOS
MacOS comes with a bit much less of a shock for key codes, however is considerably peculiar by way of mouse dealing with – the Y coordinate is inverted. Like in maths, the place X axis goes proper and Y axis goes up. To maintain issues appropriate with different platforms we’ve to subtract Y from the window peak to get a correct “laptop graphics” coordinate system (the place Y does down and (0,0) is a top-left nook of the window).
No modifications are required within the initialisation of an app, however the occasion loop ought to be modified to deal with extra occasion sorts:
static const uint8_t FENSTER_KEYCODES[128] = {65,83,68,70,72,71,90,88,67,86,0,66,81,87,69,82,89,84,49,50,51,52,54,53,61,57,55,45,56,48,93,79,85,91,73,80,10,76,74,39,75,59,92,44,47,78,77,46,9,32,96,8,0,27,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,26,2,3,127,0,5,0,4,0,20,19,18,17,0};
NSUInteger evtype = msg(NSUInteger, ev, "kind");
swap (evtype) {
case 1: f->mouse |= 1; return 0; /* NSEventTypeMouseDown */
case 2: f->mouse &= ~1; return 0; /* NSEventTypeMouseUp*/
case 5: case 6: { /* NSEventTypeMouseMoved */
CGPoint xy = msg(CGPoint, ev, "locationInWindow");
f->x = (int)xy.x;
f->y = (int)(f->peak - xy.y);
return 0;
}
case 10: case 11: ((mod & 1) << 1)
}
On macOS the important thing codes are identified constants, so we are able to outline a lookup-table to transform them to the ASCII symbols. Now we have to deal with two separate transfer occasions – one for the left mouse button being pressed (“drag”) and one other for a daily transfer with a mouse button launched. The remainder ought to be self-explanatory.
person enter on home windows
Lastly, WinAPI comes with very handy WM_
message sorts that we are able to deal with in probably the most simple approach inside our WndProc:
static const uint8_t FENSTER_KEYCODES[] = {0,27,49,50,51,52,53,54,55,56,57,48,45,61,8,9,81,87,69,82,84,89,85,73,79,80,91,93,10,0,65,83,68,70,71,72,74,75,76,59,39,96,0,92,90,88,67,86,66,78,77,44,46,47,0,0,0,32,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,17,3,0,20,0,19,0,5,18,4,26,127};
...
case WM_LBUTTONDOWN: case WM_LBUTTONUP: f->mouse = (msg == WM_LBUTTONDOWN); break;
case WM_MOUSEMOVE: f->y = HIWORD(lParam); f->x = LOWORD(lParam); break;
case WM_KEYDOWN: case WM_KEYUP: GetKeyState(VK_RWIN)) & 0x8000) >> 12);
f->keys[FENSTER_KEYCODES[HIWORD(lParam) & 0x1ff]] = !((lParam >> 31) & 1);
break;
We’re finished right here. Inside the primary occasion loop on all three platforms customers can now test for x
/y
coordinates, mouse
flag, a mod
bitmask and keys
standing array.
Timers and FPS
Since completely different computer systems have completely different efficiency it’s a standard follow to restrict the speed at which the frames are refreshed. Often, an FPS=60 (i.e. 60 frames per second, or 16.6ms per body) is used. To limit our occasion loop from rendering and polling issues sooner than that we would want two features: one to return the present time (a minimum of in milliseconds) and one other to sleep for the given period of time.
Since each macOS and Linux have some POSIX compatibility we are able to reuse the identical code:
void fenster_sleep(int64_t ms) {
struct timespec ts;
ts.tv_sec = ms / 1000;
ts.tv_nsec = (ms % 1000) * 1000000;
nanosleep(&ts, NULL);
}
int64_t fenster_time() {
struct timespec time;
clock_gettime(CLOCK_REALTIME, &time);
return time.tv_sec * 1000 + (time.tv_nsec / 1000000);
}
Home windows, then again, is particular, however nonetheless very handy:
void fenster_sleep(int64_t ms) { Sleep(ms); }
int64_t fenster_time() {
LARGE_INTEGER freq, rely;
QueryPerformanceFrequency(&freq);
QueryPerformanceCounter(&rely);
return (int64_t)(rely.QuadPart * 1000.0 / freq.QuadPart);
}
A typical software construction for our library would appear to be this (you should utilize an up-to-date “fenster.h” from https://github.com/zserge/fenster):
#embody "fenster.h"
int essential() {
uint32_t fb[320*240] = {0};
struct fenster f = { .width = 320, .peak = 240, .title = "good day", .buf = fb };
fenster_open(&f);
int64_t now = fenster_time();
whereas (fenster_loop(&f) == 0) {
/* deal with f.keys, f.mod, f.x, f.y, f.mouse */
/* replace framebuffer "fb" */
/* restrict FPS to 60 frames per second */
uint64_t ms = now + (1000/60) - fenster_time();
if (ms > 0) fenster_sleep(ms);
now = fenster_time();
}
fenster_close(&f);
}
Up to now so good. Clear, cross-platform app loop. However how can we truly draw something?
Drawing primitives
Having a cross-platform framebuffer is handy, however how we are able to flip it right into a canvas?
Let’s begin with drawing pixels. Since a framebuffer is basically an array of pixels – we are able to introduce a macro to entry a person pixel:
#outline fenster_pixel(f, x, y) ((f)->buf[((y) * (f)->width) + (x)])
struct fenster f = ...
fenster_pixel(&f, 25, 40) = 0xff0000; /* pixel at (25,40) is now crimson */
uint32_t rgb = fenster_pixel(&f, 10, 10); /* get pixel color at (10,10) */
Subsequent easy process could be to fill the entire framebuffer with a strong color:
memset(f->buf, rgb, f->width*f->peak*sizeoof(uint32_t));
Drawing rectangles isn’t that sophisticated both:
void fenster_rect(struct fenster *f, int x, int y, int w, int h, uint32_t c) {
for (int row = 0; row < h; row++)
for (int col = 0; col < w; col++)
fenster_pixel(f, x + col, y + row) = c;
}
To attract a circle we are able to use a easy algorithm that checks for each pixel inside the sq. the place the circle is inscribed. If a circle radius squared is lower than the sum of dx/dy squared (primary college math) – the pixel belongs to the circle and ought to be painted:
void fenster_circle(struct fenster *f, int x, int y, int r, uint32_t c) {
for (int dy = -r; dy <= r; dy++)
for (int dx = -r; dx <= r; dx++)
if (dx * dx + dy * dy <= r * r)
fenster_pixel(f, x + dx, y + dy) = c;
}
To attract a line we are able to use Bresenham’s algorithm, a widely known basic:
void fenster_line(struct fenster *f, int x0, int y0, int x1, int y1, uint32_t c) {
int dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
int dy = abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
int err = (dx > dy ? dx : -dy) / 2, e2;
for (;;) {
fenster_pixel(f, x0, y0) = c;
if (x0 == x1 && y0 == y1) break;
e2 = err;
if (e2 > -dx) { err -= dy; x0 += sx; }
if (e2 < dy) { err += dx; y0 += sy; }
}
}
Having this we are able to now draw advanced polygons and would in all probability want a “flood fill” algorithm. Sometimes it’s carried out utilizing a queue of pixels to test and paint, however we are able to use recursion, so long as the stuffed space stays sufficiently small to not overflow the stack:
void fenster_fill(struct fenster *f, int x, int y, uint32_t previous, uint32_t c) {
if (x < 0 || y < 0 || x >= f->width || y >= f->peak) return;
if (fenster_pixel(f, x, y) == previous) {
fenster_pixel(f, x, y) = c;
fenster_fill(f, x - 1, y, previous, c);
fenster_fill(f, x + 1, y, previous, c);
fenster_fill(f, x, y - 1, previous, c);
fenster_fill(f, x, y + 1, previous, c);
}
}
Final however not least, let’s attempt to print some textual content on display screen! To go actually old-school we will likely be utilizing a bitmap font. In different phrases, our font is an array the place every aspect corresponds to an ASCII character and describes which pixels in a glyph grid to colourise to make it appear to be a letter.
I’ve picked the smallest potential readable bitmap font the place every letter is 5 pixels tall and three pixels large (despite the fact that I’ve beforehand tried to create much smaller fonts that aren’t so readable in any respect).
To encode the 5×3 font we are able to use one uint16_t
per glyph. For instance, right here’s letters “A” and “B”:
# 010 | # # 110 A=101 1111 0111 1010
# # # 111 | # # 101 =0x5f7a
# # 101 | # # # 111 B=011 1011 1110 1011
# # # 111 | # # 101 =0x3beb
# # 101 | # # 110
If we deal with each glyph as a sequence of bits counting from the highest left nook we get 010111101111101
for “A”. However bits in numbers are normally numbered right-to-left, so we must always reverse it and get 101111101111010
. Splitting this into octets and changing them to hexadecimal type would give us 0x5F7A
. Now, how to attract such a glyph?
static uint16_t font5x3[] = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0x2092,0x2d,0x5f7d,0x279e,0x52a5,0x7ad6,0x12,0x4494,0x1491,0x17a,0x5d0,0x1400,0x1c0,0x400,0x12a4,0x2b6a,0x749a,0x752a,0x38a3,0x4f4a,0x38cf,0x3bce,0x12a7,0x3aae,0x49ae,0x410,0x1410,0x4454,0xe38,0x1511,0x10e3,0x73ee,0x5f7a,0x3beb,0x624e,0x3b6b,0x73cf,0x13cf,0x6b4e,0x5bed,0x7497,0x2b27,0x5add,0x7249,0x5b7d,0x5b6b,0x3b6e,0x12eb,0x4f6b,0x5aeb,0x388e,0x2497,0x6b6d,0x256d,0x5f6d,0x5aad,0x24ad,0x72a7,0x6496,0x4889,0x3493,0x2a,0xf000,0x11,0x6b98,0x3b79,0x7270,0x7b74,0x6750,0x95d6,0xb9ee,0x5b59,0x6410,0xb482,0x56e8,0x6492,0x5be8,0x5b58,0x3b70,0x976a,0xcd6a,0x1370,0x38f0,0x64ba,0x3b68,0x2568,0x5f68,0x54a8,0xb9ad,0x73b8,0x64d6,0x2492,0x3593,0x3e0};
static void fenster_text(struct fenster *f, int x, int y, char *s, int scale, uint32_t c) {
whereas (*s) {
int chr = *s++;
for (int dy = 0; dy < 5; dy++)
for (int dx = 0; dx < 3; dx++)
if (font5x3[chr] >> (dy * 3 + dx) & 1)
fenster_rect(f, x + dx * scale, y + dy * scale, scale, scale, c);
x = x + 4 * scale;
}
}
And we’re lastly able to create some visible masterpieces:
Sound
We supply on. Up to now we’ve conquered essential app loop, framebuffer/canvas and person enter. However why do all of it in silence? Can we add some audio playback?
Audio will be very sophisticated, and low-level audio much more so. But when we solely give attention to probably the most primary use circumstances – like, play a mono stream of floating-point audio samples – we are able to make it work somewhat simply on many of the computer systems.
It’s fairly a protected guess to decide on ALSA for Linux (because it’s a part of the kernel), WinMM for WinAPI (because it’s been there for ages) and CoreAudio for macOS.
Many audio libraries select a callback API mannequin to present person extra management over the latency and buffering. In our case, to maintain the simplicity of the polling loop from the above let’s select the synchronous “streaming” method. We would want to implement 4 features:
- One to open the default audio gadget.
- One other to shut it.
- One to get the variety of accessible samples to be written into the audio stream.
- One other to truly write them.
The implementation ought to assure that any try to write down a buffer of samples not longer than the accessible quantity – it won’t block. Then the caller ought to test the accessible rely, deal with rendering/mixing sound and writing that a part of the buffer.
ALSA implementation could be the best of all of them:
int fenster_audio_open(struct fenster_audio *fa) {
if (snd_pcm_open(&fa->pcm, "default", 0, 0)) return -1;
int fmt = (*(unsigned char *)(&(uint16_t){1})) ? 14 : 15;
return snd_pcm_set_params(fa->pcm, fmt, 3, 1, FENSTER_SAMPLE_RATE, 1, 100000);
}
int fenster_audio_available(struct fenster_audio *fa) {
int n = snd_pcm_avail(fa->pcm);
if (n < 0) snd_pcm_recover(fa->pcm, n, 0);
return n;
}
void fenster_audio_write(struct fenster_audio *fa, float *buf, size_t n) {
int r = snd_pcm_writei(fa->pcm, buf, n);
if (r < 0) snd_pcm_recover(fa->pcm, r, 0);
}
void fenster_audio_close(struct fenster_audio *fa) { snd_pcm_close(fa->pcm); }
On macOS and WinAPI we must use double-buffering approach, the place a number of audio buffer objects are created and chained for the playback, and as quickly as the primary one is completed – the subsequent one continues the playback, whereas the primary one is being fulfilled with the next audio samples.
I wouldn’t publish the entire code right here, however you possibly can test it out on Github.
Bindings
C is a pleasant small language, however for fast prototyping possibly another, much less archaic languages may match higher.
There are Go bindings utilizing CGo and the library will be imported like a traditional Go module. @dim13 has offerred a couple of changes to the API in order that our framebuffer is accessible as a typical picture.Picture
interface. This permits compatibility with many different libraries, reminiscent of https://github.com/fogleman/gg or https://github.com/llgcode/draw2d.
Different bindings that have been trivial to write down have been in Zig. In actual fact, Zig so effectively integrates with C code that it will probably use Fenster library straight:
const std = @import("std");
const c = @cImport({
@cInclude("fenster.h");
});
pub fn essential() void {
var buf: [320 * 240]u32 = undefined;
var f = std.mem.zeroInit(c.fenster, .{.width = 320, .peak = 240, .title = "good day", .buf = &buf[0]});
_ = c.fenster_open(&f);
defer c.fenster_close(&f);
var t: u32 = 0;
var now: i64 = c.fenster_time();
whereas (c.fenster_loop(&f) == 0) {
// Exit when Escape is pressed
if (f.keys[27] != 0) {
break;
}
// Render x^y^t sample
for (buf) |_, i| {
buf[i] = @intCast(u32, i % 320) ^ @intCast(u32, i / 240) ^ t;
}
t +%= 1;
// Preserve ~60 FPS
var diff: i64 = 1000 / 60 - (c.fenster_time() - now);
if (diff > 0) {
c.fenster_sleep(diff);
}
now = c.fenster_time();
}
}
After all, some Zig-friendly idiomatic wrappers are all the time welcome!
However can it run Doom?
Sure, it will probably! Porting Doom has by no means been simpler as of late, due to the great https://github.com/ozkl/doomgeneric. All you need to do is to implement a number of features: for opening a window, for updating the framebuffer, for dealing with time and for person enter. We’re fortunate – Fenster actually has an API name for every of those circumstances.
Should you solely override these features you already get a well-recognized beginning image and a few demo animation:
struct fenster f = { .width = DOOMGENERIC_RESX, .peak = DOOMGENERIC_RESY, .title = "doom" };
void DG_Init() { f.buf = DG_ScreenBuffer; fenster_open(&f); }
void DG_DrawFrame() { fenster_loop(&f); }
void DG_SleepMs(uint32_t ms) { fenster_sleep(ms); }
uint32_t DG_GetTicksMs() { return fenster_time(); }
What’s left is person enter. Doom makes use of key occasions as an alternative of key statuses, and it peeks them one after the other. So we must always hold one other array of final know key statuses, examine it to the present keys and return an occasion if there’s a mismatch. Moreover, Doom has its personal customized key mapping which may not match our key codes, so we have to do some conversion:
unsigned char toDoomKey(int okay) {
swap (okay) {
case 'n': return KEY_ENTER;
case 'x1b': return KEY_ESCAPE;
case 'x11': return KEY_UPARROW;
case 'x12': return KEY_DOWNARROW;
case 'x13': return KEY_RIGHTARROW;
case 'x14': return KEY_LEFTARROW;
case 'Z': return KEY_FIRE;
case 'X': return KEY_RSHIFT;
case ' ': return KEY_USE;
}
return tolower(okay);
}
int DG_GetKey(int *pressed, unsigned char *doomKey) {
static int previous[128] = {0};
for (int i = 0; i < 128; i++) {
if ((f.keys[i] && !previous[i]) || (!f.keys[i] && previous[i])) {
*pressed = previous[i] = f.keys[i];
*doomKey = toDoomKey(i);
return 1;
}
}
return 0;
}
Unbelievable, however in lower than 50 traces of code we obtained a totally working Doom sport working on high of a tiny cross platform graphics library we’ve simply created!
Nicely, if it will probably run Doom – it will probably do something! I hope this little toy library could be useful to those that miss the simplicity of the old fashioned graphics. Pull requests, bug fixes and contributions are appreciated, so long as they don’t bloat the library and hold it easy.
I hope you’ve loved this text. You may observe – and contribute to – on Github, Twitter or subscribe by way of rss.
Jan 15, 2023