How to Render Images from Scratch Using C - Drawing Bitmaps

jason on July 13, 2025, 01:08 PM

Today I'm gonna walk through some C code that will render images at specific locations on a screen. Sounds simple enough right? Just tell the computer you want your image to show up 5 pixels to the left and 5 pixels from the top and done! Right? Well... Have you ever wondered what's going on in the shadows that makes that magic possible? Let's take a slightly lower level look at what goes on when you want to render images, or as we'll refer to them from now on bitmaps (because yeah, they're just maps of bits!)

Ok so the first thing is, you'll need to start off with a byte array of the pixel information. In this example, we're going to use a black and white image since it's the most simple type of bitmap you can use. Now, the thing to keep in mind about a byte array is that each byte holds 8 bits of information, but since our image is black and white, we only need 1 bit of data to represent each pixel. This means that each byte in the array will hold exactly 8 pixels. We need to keep this in mind because it's easy to make the mistake of thinking our image data is an array of pixels when in actuality, it's an array of 'chunks' of pixels.

So, let's start off by creating the function that's going to handle doing the rendering for us. since it won't return anything, we'll start if off with the void keyword. Then, for the parameters, we'll let it take a pointer to a byte array that will be the buffer for the rendered image, a pointer to another byte array that will hold the pixel data we want to draw into that buffer, an integer that will be the x position, and another integer for the y position, and two more ints for the width and height of the bitmap image. so when we write than in C, it would look like this:

void DrawBitmap(unsigned char* buffer, const unsigned char* bitmap, int x, int y, int bitmapWidth, int bitmapHeight) {
  // our logic will go here
}

Note that both the buffer and bitmap we'll be pointing are unsigned char arrays. They're arrays of char because in C, char represents the smallest amount of memory that can be processed at one time (usually a byte or 8 bits), and it's unsigned because we don't need negative values in representation of our pixel data. 0-255 will work just fine.

Ok, so now that we understand what's going into our function, let's start filling our the logic for actually rendering the pixel information. The main idea is that, we have a buffer that will be sent to the display and the display will turn some pixels on and some others off based off of the values in the buffer. Our buffer will start off being filled with zeros so it would just render a black screen. If you need a refresher on what a buffer is, just think of it as a section of the computer's memory where values can be read from and written to. That's all it really is. So in this case, we're going to be writing pixel information to the buffer so the display can read from it.

Since our display will be reading directly from the buffer, we'll want to first define a width and height that match that of the screen. This is crucial, because the bitmap data will be coming into the function as a one dimensional array and we need to tell the function at which point the bits should wrap around down to the next line. In this example, we'll assume our display is 128 by 64 pixels, so we'll define those values as integers at the top of the function. Of course, they'll be constants because those won't ever need to change while the function is running.

void DrawBitmap(unsigned char* buffer, const unsigned char* bitmap, int x, int y, int bitmapWidth, int bitmapHeight) {
  // Our display is 128x64 pixels
  const int bufferWidth = 128;
  const int bufferHeight = 64;
...
}

So, the next thing we need to know is how many bytes wide our bitmap is based off it's pixel width. For example, if the bitmap is 32 bits wide it's pixels would fill up 4 bytes (because 32 / 8), before we would need to continue on to the next row of pixels. So to calculate the bitmapByteWidth, we'll add 7 to the bitmapWidth and divide that by 8, which would look like this:

int bitmapByteWidth = (bitmapWidth + 7) / 8;

Note, in C and C++, whenever you do division between two integers, the result is always an integer. This is called integer division. It basically means that any fractional portion just get's truncated, so for example:

3 / 8 results in 0
11 / 8 results in 1
23 / 8 results in 2

Adding 7 to the bitmapWidth ensures that we'll always have a bitmap width large enough to hold all of our pixels. For example, if the bitmap was only 4 pixels wide, and we didn't have the 7 there, integer division would give us 0. That obviously doesn't make sense because we'll need at least one byte to hold the 4 pixels. Also, imagine if the bitmap was 9 pixels wide. In that case, integer division would give us 1, which obviously wouldn't be enough because we'd need two bytes to hold all those values.

The Loop

Ok, so now that we've got all that set up, let's start building out the loop. An image is just a matrix. This is a really simple matrix because it's really just a bunch of individual bits that are either ones or zeros. So all we need to do is loop through each row of the bitmap and each pixel in each one of it's columns and write those values to the appropriate place in our buffer. let's start writing the loop.

void DrawBitmap(unsigned char* buffer, const unsigned char* bitmap, int x, int y, int bitmapWidth, int bitmapHeight) {
  // Our display is 128x64 pixels
  const int bufferWidth = 128;
  const int bufferHeight = 64;

  // Calculate bitmap byte width (each byte contains 8 pixels horizontally)
  int bitmapByteWidth = (bitmapWidth + 7) / 8;

  // For each row in the bitmap
  for (int yOffset = 0; yOffset < bitmapHeight; yOffset++) {
    // Skip if this row is outside the buffer
    if (y + yOffset < 0 || y + yOffset >= bufferHeight) continue;
    
    // We'll loop through each pixel in the row here
  }
}

All we're doing here is using the bitmap's height to tell us how many rows of pixels we have and looping through each one of those rows. For example, if the bitmap we wanted to draw was 32x16 pixels, meaning we have 32 rows and 16 columns, we need run the loop 32 times to process each row of 16 pixels. Since the buffer will contain all of the pixels that the display will render, it doesn't make sense to try and write values to it that we wouldn't be able to see. When the buffer is read by the display, the display will treat it like an a 2 dimensional graph where anything that's outside of the bounds won't be visible. In our case, since the display is 128x64 pixels, that means any y value that's less than zero or greater than or equal to the buffer's height dimensions wouldn't be visible, so we'll just skip the rest of the logic in the loop with a continue statement in those cases.

Now that we have that part done, we need one more loop that we'll use to iterate through each one of the pixels in the rows. We're almost ready to make the magic happen because in this loop, we'll decide which of the buffer's zeros we want to flip to ones. Like I mentioned earlier, the buffer starts off representing a black screen because all of the values in it are zeros. Ones inside of the buffer will represent white pixels, which we'll draw our images by flipping to an on state.

...
// For each column in the bitmap
for (int xOffset = 0; xOffset < bitmapWidth; xOffset++) {
  // Skip if this column is outside the buffer
  if (x + xOffset < 0 || x + xOffset >= bufferWidth) continue;
      // here, we'll write the logic that flips certain 
      // buffer values from zeros to ones
}
...

The looping logic for each pixel in a row is pretty similar to what we did for each row. We basically just want to do one loop for every column of pixels we have in a row and skip over the rending portion of the logic if we are outside of the bounds of the display's dimensions.

Integer division is a really useful property here too because we can just do it here to decide which byte in the byte array of the bitmap our current pixel is in by dividing the xOffset (our current pixel index) by 8 (since there are 8 bits in a byte). For example, if the xOffset is 25, 25 / 8 results in 3 for integer division so we know this pixel is inside of our 4th byte (because the byteIndex is 3). That will tell us which byte the current pixel is in for one particular row, but since we're looping through each of the bitmap's rows, we also need to add the yOffset * bitmapByteWidth to it. The yOffset value tells us which iteration of the outer loop we're in which is the row of the bitmap.

Remember how we calculated the byteWidth of our bitmap earlier? Now we need to multiply it by the yOffset because the byteWidth tells us how many bytes we'd need to jump over to be on the next row of the bitmap, so multiplying it with the current row tells us which byte the iteration is currently in.

Next, we need to calculate the bitIndex. We'll do that like this:

int bitIndex = 7 - (xOffset % 8); // Most significant bit first

Here, we're representing the index of the bit in MSB or Most Significant Bit format which means we're reading the indicies of the bits in our bytes from left to right, or highest order first. This is the opposite of LSB (Leaset Significant Bit) where we'd count the indices from right (lowest order bit) to left. xOffset % 8 gives us the remainder when xOffset is divided by 8. This results in a value from 0 to 7, representing the bit position within a byte. 7 - (xOffset % 8) inverts the bit position, so bit 0 becomes 7, bit 1 becomes 6, ..., bit 7 becomes 0.

Bitmasking

Ok, so we're almost ready to flip some bits. But first, we need to know when the current pixel we're at in our loop is on or not. We'll do that with bitmasking.

// Check if this bit is set in the bitmap
if (bitmap[byteIndex] & (1 << bitIndex)) {
  // we'll fill in this logic next
}

Ok, so at this point, we need to make a comparison. Since the comparison is of two binary numbers instead of something like integers or floats, we need to do bitwise operations. So the main goal right here is to check if the position of our current bit is on or not. What's cool about bitwise operations, is they allow us to use a single integer represented in its binary form to store and manipulate multiple booleans. So in this case, since we're comparing bytes we can think of each byte as a collection of 8 booleans. We could also break this logic out into 8 bool variables but that wouldn't be memory efficient because processors still use a whole byte when to represent a bool even though it's conceptually just a 1 or a 0.

ok so let's break this down...

The << operator is the bitwise left shift operator, which take two operands. On the left side is the binary number and on the right side is the amount of spaces you want to shift left in that binary number. So we want to shift the binary number 1, or in this case since we're dealing with a byte 00000001 a certain number of places to the left... And since we know the position of the bit we want to check, we can shift it that many places to the left to see if at that same position in our bitmap byte, we have a match. If not, our bitwise operation returns all zeros, which evaluates to false. If there is a match, our bitwise operation returns 1 and since that's non-zero, we have a true statement. This means this particular pixel is 'on' in our bitmap byte array we can now turn on the appropriate one in our display buffer.

So if we know we have an 'on' pixel at this specific location in the byte, we can calculate it's corresponding position in the buffer and finally flip the buffer's zero to a one. We need two things to make this work: the specific buffer location that we need to write to, which we'll call bufferIndex, and the position of the bit in that byte, or memory location that we want to write to. So, to calculate the bufferIndex, we'll use the following formula:

int bufferIndex = (x + xOffset) * (bufferHeight / 8) + ((y + yOffset) / 8);

What we're doing here is getting the specific horizontal position of the current pixel with x + xOffset, multiplying that times bufferHeight / 8 which is the amount of bytes needed to give us the pixel height of the buffer, then finally adding all that to (y + yOffset) / 8 which is the specific vertical position of the current pixel divided by 8 (again because 8 of those bits fit into one byte). Kind of a lot going on here, but just roll with it haha. Next, we'll calculate the bit position, we'll call bufferBitPosition, like this:

int bufferBitPosition = 7 - ((y + yOffset) % 8); // MSB format

This line is almost the same as what we did above with calculating the bitIndex in the bitmap. All we're doing is getting our specific vertical position with y + yOffset and using the modulo operator to check if there's any remainder when dividing it by 8. If there is, it means that position would 'spill over' into the next byte, so we can subtract that remainder from 7 to find out which bit in the current buffer we want our index at. The last thing we need to do is use the bufferIndex to target the buffer location we need to write to and write a 1 to the positon in it that corresponds to the bufferBitPosition.

// Set the bit in the buffer
buffer[bufferIndex] |= (1 << bufferBitPosition);

The |= symbol is called the bitwise OR assignment operator. It takes whatever is on the left side of it, compares it to whatever is on the right side and outputs a modified version of the left side where each bit fits the OR condition. What this essentially means is it lets you take whatever is on the left side and set 0s to 1s wherever they exist in the right hand operator. Since we know the buffer bit position, we can basically create a byte with a 1 in that position and use the bitwise OR assignment operator to set that bit in the display buffer.

The Complete DrawBitmap Function

So, to finally put it all together, our function to display bitmaps would look like this:

void DrawBitmap(unsigned char* buffer, const unsigned char* bitmap, int x, int y, int bitmapWidth, int bitmapHeight) {
  // Waveshare 2.42 inch OLED is 128×64 pixels
  const int bufferWidth = 128;
  const int bufferHeight = 64;

  // Calculate bitmap byte width (each byte contains 8 pixels horizontally)
  int bitmapByteWidth = (bitmapWidth + 7) / 8;

  // For each row in the bitmap
  for (int yOffset = 0; yOffset < bitmapHeight; yOffset++) {
    // Skip if this row is outside the buffer
    if (y + yOffset < 0 || y + yOffset >= bufferHeight) continue;

    // For each column in the bitmap
    for (int xOffset = 0; xOffset < bitmapWidth; xOffset++) {
      // Skip if this column is outside the buffer
      if (x + xOffset < 0 || x + xOffset >= bufferWidth) continue;

      // Calculate the bit position in the bitmap
      int byteIndex = (yOffset * bitmapByteWidth) + (xOffset / 8);
      int bitIndex = 7 - (xOffset % 8); // Most significant bit first

      // Check if this bit is set in the bitmap
      if (bitmap[byteIndex] & (1 << bitIndex)) {
        // Calculate the buffer position
        int bufferIndex = (x + xOffset) * (bufferHeight / 8) + ((y + yOffset) / 8);
        int bufferBitPosition = 7 - ((y + yOffset) % 8); // MSB format

        // Set the bit in the buffer
        buffer[bufferIndex] |= (1 << bufferBitPosition);
      }
    }
  }
}

And we could call it with a bitmap byte array like this:

const unsigned char bitmap [] PROGMEM = {
  0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 
  0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x7f, 
  0xfc, 0x1f, 0xfb, 0x1f, 0xfb, 0x0f, 0xf3, 0x1f, 0xfb, 0x0f, 0xf0, 0x0f, 0xf8, 0x0f, 0xf0, 0x07, 
  0xf8, 0x0f, 0xf0, 0x07, 0xf8, 0x0f, 0xf8, 0x07, 0xfc, 0x4f, 0xfc, 0x2f, 0xfe, 0x1f, 0xfe, 0x1f, 
  0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 
  0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0xff, 0xff, 0xfc, 0x3f, 0xff, 0xff, 0xff, 0xff, 0xff, 
  0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 
  0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
};

DrawBitmap(ImageBuffer, bitmap, 0, 0, 32, 32);

And that's it! Now, we can easily create animations by calling this function in a loop where we send different bitmaps to it like a digital flipbook. I'm sure there are more efficient ways to do 2D animations than just rendering bitmaps in sequence, but this is probably the simplest way to accomplish it. At least that I know of so far!

Share this Post

1
95
1

I kinda forgot to mention, but the same concept should apply for color images. It’s just that you’d need three integer values for each pixel to represent the red, green, and blue color channels. A 24 bit image gives pretty good color depth because you get values 0-255 for every channel if using a byte for each one. I should look more into how to do that though because I’m sure there are some optimizations I’m not aware of.