Times have changed, and almost everyone has some sort of 3D acceleration hardware even if it's bundled with their CPU. CPUs have more cores. Resolutions are higher. More people have multiple monitors. The optimal graphics pipeline is very different than it once was. CPUs are also faster, so it's not like you can't do software rendering, but you're leaving a lot of performance on the table that could be used to do more stuff per frame in your game. So SDL2 was made to address modern realities while keeping a nice high level API to create 2D games. Things aren't that different, but now you can get a huge performance boost, because SDL2 will do hardware stuff behind the scenes on your behalf.
I've played with SDL2 a few times since it's been out, and each time left not too satisfied. For some reason I never bothered to read the Migration Guide. Reading it this time I've convinced myself SDL2 is the right approach and worth relearning a few things and changing approaches to take advantage of it. In other words it's time to get with the times. The guide also explains the main approaches people have used to make 2D games and how each approach can be, with few drastic changes, done just fine with SDL2, but now can be much faster.
So for my new "hello world" I've taken the first approach of "I just want to get fully-rendered frames to the screen". This is rather straightforward -- you manage your own "pixel buffer" in RAM, and when you're ready copy it to a texture (GPU memory) and finally copy the texture to the renderer (GPU memory used for the final paint to screen). Controlling each pixel in RAM still leaves you with fundamentally a software renderer design, but you're able to easily take advantage of modern hardware features like 'free' scaling and aspect ratio adjustment and everything else SDL2 can do for you.
Now there's only one hiccup left: I want to use Common Lisp for everything. This isn't such a hiccup after all, it is easy to wrap C libraries and call into them. Here is an example I did for the FMOD sound library: fmod-test.lisp. With SDL2, you don't even have to wrap it yourself, because a small group of people did it already and maintain and update it from time to time. All you need to do is add sdl2 to your dependencies.
Now, a wrapped C library is no PyGame. Even in the Python world, you can easily load a compiled C library and call into it, but the more you need to do the less pretty it becomes. Similarly with Lisp auto-wrapped C libs. To have a truly user-friendly tool like PyGame but in Lisp you'd probably need a combination of some new C code as well as a bunch of new Lisp code to wrap things up in nice abstractions. There isn't a Lisp equivalent to PyGame yet. (Though there are many attempts at higher level Lisp-friendly interfaces for game programming, some using SDL, some using OpenGL.)
In the spirit of that previous Python post though, I think it's necessary to see the ugly details, then you can decide whether various approaches to prettifying are worthwhile to you or not, or perhaps you can do things better. If you look at cl-sdl2's basic renderer test (or various other examples) you'll see some attempts at helpful abstraction: handling init, handling window screen creation, handling renderer creation, and handling the event loop. So many macros!
But I'm not convinced they are good. In one of my previous excursions (this happened in SDL1 as well, because the CL wrapper encourages similar macros there) if you try and write a game with them that also takes advantage of the Lisp approach of incremental development, you're gonna have a bad time. Having everything in a giant main function with several nested levels of indentation is no way to do good software development, and you can't just recompile that giant method and have changes occur because the main loop is still in the old method. You need to break stuff up. But do that with the macros as they are, and you'll find that you'll sometimes recompile certain things and yet the changes you expect to see don't happen until you close the window and start over.
If you look at the code for sdl2:with-init you'll see some scary stuff about making sure things run in the "main thread" (mainly for OSX compatibility) -- the SDL Migration Guide and other sources mention similar things -- but if you were naively expecting single-threaded behavior until you include your own threads, too bad. And yet despite that you still have to figure out your own threading solution anyway to be able to launch your game from the REPL and then continue compiling new code / doing other stuff with slime that you expect to be able to do. It's not hard, but the macros are deceptive.
Apart from that, the more wrapping you do without good documentation, the harder it is to actually use the underlying C library effectively. Let's take the first bit of code anyone sees trying to use this wrapper:
(sdl2:with-init (:everything) ...
What is :everything? Can it be something different? If I look at the doc, I only see that its lambda-list supports a list of SDL init flags. That's enough to search and find the doc for SDL_Init which describes the various flags. One might then infer if I didn't specify everything, I could instead specify e.g. the list (:video :events) to get just those two and not have to worry about the underlying bitwise-or. But I shouldn't have had to search the web. Alternatively, I could jump to the source code. No comments, but that's ok, it's simple code. The workhorse is the init function which does have a function doc! It says the default is everything -- so why bother passing it in the macro example? But we see it uses autowrap:mask-apply (what's that? No function doc, we have to read the source, and discover it's just the apply version of the function mask which does have function doc and makes it clear that it's doing a logior of the symbols in the list) on a quoted sdl-init-flags and the passed in argument. vim's gD manages to jump to what seems the definition of the mask using autowrap:define-bitmask-from-constants (sdl-init-flags) and at last we see sdl2-ffi constants corresponding to the enums shown in the SDL doc.
This short journey revealed some cool utilities of cl-autowrap that make working with enum bitmasks (while idiomatic lisp would use lists of keywords) easier, but again, it's rather a lot of effort to fully understand how you can use the first bit of wrapper code you're exposed to. We see some scary code related to OSX and at least one convenient unwind-protect that makes sure to call SDL_Quit, but this is nothing we can't do ourselves (or ignore for now since we're on Linux).
In any case, this does not inspire confidence that whatever on-top "ease of use" things have been added to cl-sdl2 are actually easy to use... You have no indication of what errors you need to be aware of that need handling, and macros can make it deceptive that errors will be handled cleanly for you.
My philosophy in these cases is to talk to the library as directly as possible, and use the extra stuff in the wrapper as inspiration for my own wrappers. For instance we see in the next macro with-window that they do in fact check the window for a null pointer and raise an 'sdl-error if it's one, I might want to handle that case with some special code! Not mentioned in the doc for with-window however (there is none). Meanwhile SDL_CreateWindow is clear about the error possibility. I'm ok with just checking errors directly myself, e.g. null pointers with (cffi:null-pointer-p (autowrap:ptr screen)) and keeping the appropriate level of paranoia C demands.
Let's finally look at the code. (Duplicated on gist here.) Given all that effort for just one macro starting from the Lisp side, I just started from looking at the C side from the migration guide instead. This is faster, can just apropos/tab-complete the autowrapped Lispy-named function or constant equivalents. In the end I created a hello world that draws a red/green/blue banner with a CL pixel array.
#|Public domain. Does not unwind-protect errors like it should.|#
(defun null-ptr? (alien-val)
(cffi:null-pointer-p (autowrap:ptr alien-val)))
(defun render-then-quit (&aux (w 800) (h 600) sz screen renderer texture buffer buffer-ptr)
(setf sz (* w h))
(unless (zerop (sdl2-ffi.functions:sdl-init sdl2-ffi:+sdl-init-video+))
(error "Could not init"))
(setf screen (sdl2-ffi.functions:sdl-create-window
sdl2-ffi:+sdl-windowpos-undefined+ sdl2-ffi:+sdl-windowpos-undefined+ ; let the OS position window
(if (null-ptr? screen)
(error "Could not make window screen"))
(setf renderer (sdl2-ffi.functions:sdl-create-renderer
screen -1 0)) ; default monitor, no flags like vsync
(if (null-ptr? renderer)
(error "Could not make renderer"))
(setf texture (sdl2-ffi.functions:sdl-create-texture
(if (null-ptr? texture)
(error "Could not make texture"))
(setf buffer (make-array sz :initial-element 0 :element-type '(unsigned-byte 32)))
(loop for x from (* w 150) to (* w 250) do
(setf (aref buffer x) #xFFFF0000)) ; red
(loop for x from (* w 250) to (* w 350) do
(setf (aref buffer x) #xFF00FF00)) ; green
(loop for x from (* w 350) to (* w 450) do
(setf (aref buffer x) #xFF0000FF)) ; blue
(setf buffer-ptr (cffi:foreign-array-alloc buffer `(:array :uint32 ,sz)))
;(cffi:lisp-array-to-foreign buffer buffer-ptr `(:array :uint32 ,sz)) ; if ptr allocated separately
(unless (zerop (sdl2-ffi.functions:sdl-update-texture texture nil buffer-ptr (* w (cffi:foreign-type-size :uint32))))
(error "Could not update texture"))
; technically clearing is not needed since the texture is the size of the screen, but
; if another program drew over the display (like steam overlay) this would make sure
; it doesn't hang around.
(sdl2-ffi.functions:sdl-set-render-draw-color renderer 0 0 0 255)
(sdl2-ffi.functions:sdl-render-copy renderer texture nil nil) ; copy the whole thing
I added some error checking after a few iterations but not in a robust way -- i.e. unwind-protect should be used to only SDL_Quit after Init has succeeded but something else failed, should unwind-protect the destruction of foreign array/texture/renderer/window and only destroy them if they are non-null. Further refinements would be to report on what SDL said the problem was, to potentially throw up a new SDL Error Modal...
It took me a bit to figure out how to properly convert my CL pixbuf array into a foreign array I could send to SDL, but it's not hard. Unfortunately it does add one more layer of inefficiency to this hello-world of a software renderer. It exists in RAM as Lisp data, but when we're done with it, we have to copy it to a foreign array in RAM, then copy that array to a texture in GPU memory. We could drop the Lisp array and just allocate a foreign array to work on directly, but if most of our pixel data is coming from Lisp data, then it may be better to pay the conversion to foreign data once when copying the whole array instead of each time we copy a bit of Lisp data to a bit of the foreign array. Same consideration if we used an SDL_Surface instead.
Anyway, it's a nice start, and shows all the pieces. I think seeing this nearly-direct-as-in-C version of using SDL is more informative for what sorts of abstractions will be useful to have a nicer time in Lisp, and to better understand which wrappers to take advantage of in cl-sdl2 and elsewhere and which to avoid or change.
Posted on 2020-11-15 by Jach