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.