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.