Implementing a Fast Pixel-Art Drawing Program

21 of February 2025 (Home)

This article is an attempt of summing up what I learned after spending several months implementing a pixel-art drawing program in C, and then spending an equal amount of time implementing an improved successor. My code is optimized for simplicity and performance. I was originally motivated after discovering that my low-spec laptop was completely unable to run (much more compile) Aseprite, a drawing program widely, and falsely, considered to be lightweight.

The source code is hosted on Codeberg, and I regard it as well-documented, at least in my biased point of view :)

Choice of Technology

After experimenting for a couple a months, and leaping from language to language, I was left disappointed. Most languages and frameworks would either result in heavy binaries, or require huge compile times that my poor laptop just wasn't designed for. C was an obvious winner. As for the renderer, my initial version (which you can still find on my Codeberg page) was based on SDL_Renderer. The performance was great, but I ultimately decided to rework on a new version offering a custom and thin abstraction over OpenGL!

It is now most appropriate to note that I taught myself graphics programming by merely messing around with online repositories and studying documentation segments. I lack foundation and I haven't read a book on the subject yet, neither have I attended a course. My approach is most likely not the smartest out there, but that doesn't change the fact that the binaries turned out impressively performant.

Drawing Commands

The canvas_t struct can be viewed as the heart of the drawing program. It is entirely abstracted away from the visual interface, and its sole purpose revolves around managing pixel state, history, brush preferences and the clipboard. Some canvas_t struct fields relate to optimizations which I will explain later.

Despite what you might have heard, C is a high-level programming language. Throughout the source code, I've made excessive use of enums, arrays and function pointers. Think of that as a workaround to virtual functions, but strangely enough more elegant and performant, without the usual mess and nonsense introduced by object-oriented programming languages!

The program features symmetry options, which can be enabled via flags. The canvas_apply_brush function wraps around brush handlers (which define shapes!) and shape fillers, which wrap around canvas_set_pixel and thus define materials! Let's dive deeper by examining canvas_set_pixel, the building block of all drawing operations. Although the implementation might initially seem unnecessarily complex, everything is essential to ensure optimal performance.

Simple Optimizations

The existence of the canvas->were_pixels_modified flag and the canvas->editing_areas array is fundamental. Since the texture is stored on the GPU, it proved way too inefficient to constantly issue a CPU-to-GPU trip whenever an elementary drawing command was triggered. So instead, I decided to batch them up: During the time that the mouse button remains pressed, canvas_set_pixel will update the CPU reflection of the state (which is stored in canvas->data) and incrementally adjust a temporary and internal rectangular area that should enclose as minimally as possible the portion of the texture that has thus far been modified, but has not yet been registered into the GPU.

This was not a premature optimization, it stemmed from experience with earlier versions of the project! During the last paragraph, I told a deliberate lie in favor of simplicity. The program will actually store 4 different editing areas, with all 4 of them being used only when all symmetry options (horizontal and vertical) are enabled. Note that these supplementary areas are not necessarily reflected copies of the original (take the bucket tool, for example), so that's why they need to be stored separately.

The clipboard and history features work in much of the same way. Interestingly, the original implementation of the bucket tool, being recursive, failed on large canvases due to stack overflows, so it was eventually replaced with an iterative implementation over a queue! More interestingly (while not being related to optimizations), canvas_grayscale_shape_filler does not simply compute the numerical average of all channels when generating pixet_t {avg, avg, avg}. The human eye is not equally sensitive to all colors, so a weighted average was necessary instead!

File Formats

This project marked the very first time I dabbled with the QOI image format! The decoder-encoder is just over 300 well-documented lines of C code, with performance similar to that of massive PNG libraries (at least when it comes to my standards!). The canvas_io.h implementation is again, unsurprisingly, heavily dependent on elegant function pointers. Effectively, the program can also function as an image file converter, translating PNG files to QOI and vice-versa! It was a happy realization :D

Front-end Implementation

There's really not much to talk about here! The editor is keyboard-based so the interface is deliberately minimal. The canvas->editing_areas back-end optimization is exploited and visually reflected using only a handful of glTexSubImage2D calls. In case the user wishes to delete the entire canvas and start anew, the program will simply call glClearTexImage since that proved way faster than naively copying huge arrays of zeroes into the texture buffer.

The rest of the code in canvas.c is just me desperately trying to abstract OpenGL into something somewhat readable, with debatable success. An experienced graphics programmer will probably laugh at my attempt, but I'm satisfied with it! The program was successfully used in the development of Zer Z600, although I must admit that some key features are still missing, most notably canvas resizing and tabs.