The goal of this post is to add to the #DIYConsole graphics library the functions to display on the screen characters and text.

To draw a text string we need a font, i.e. a set of graphically related glyphs. Typically, the fonts used on a modern computer are vector fonts: the shape of each glyph is stored as a set of Bézier curves that define its outline. The primary advantage of this type of fonts is that they can be scaled to any size without loss in quality, but they require considerably more processing power and may yield unpleasant rendering (especially for small sizes).

A type of fonts better suited to the limited computing power of a microcontroller are bitmap fonts. In a bitmap font, each glyph is stored as an array of pixels. The font itself is simply a collection of bitmaps for each glyph. They have the advantage of being quick and easy to render, but the price to pay is that quality tends to be poor when they are scaled. Alternatively, if you need to use the same font in multiple dimensions, you can provide optimized fonts for each one, dramatically increasing memory usage.

So, for our graphics library we will use bitmap fonts. To limit memory usage, we will make only one monochrome bitmap font and will look for a way to scale it avoiding pixelation.



An example of bitmap glyph

 

Drawing monochrome bitmaps

The first step is to write a function to draw monochromatic bitmaps. In monochromatic bitmaps, each bit represents a pixel: if its value is 1, the pixel is drawn on the screen with the selected color, otherwise it is skipped.

Like the bitmaps we saw in the previous post, the data is stored as a sequence of lines. They can have any width, but to make access to bits easier each line is rounded to an 8-bit (one byte) boundary, padded with zeroes on the right.



Example: a 5×7 bitmap is stored as a sequence of 7 bytes
where the last 3 bits of each are set to 0.

Add this declaration to the GFX class:

void drawMonochromeBitmap(uint8_t* bitmap, int16_t x, int16_t y, uint16_t width, uint16_t height, uint8_t color);

and the definition in GFX.cpp:

void GFX::drawMonochromeBitmap(uint8_t* bitmap, int16_t x, int16_t y, uint16_t width, uint16_t height, uint8_t color) {
    int widthBytes = ceil((double)width / 8);
    for(int v=0; v<height; v++) {
        for(int u=0; u<width; u++) {
            if(ONSCREEN(x + u, y + v)) {
                int b = u >> 3;     // the byte to select
                int bit = ~u & 7;   // the bit to read
                if(bitRead(bitmap[widthBytes * v + b], bit))
                    drawPixel(x + u, y + v, color);
            }
        }
    }
}

 

Fonts

As we saw earlier, a bitmap font is a collection of bitmaps representing individual glyphs. The simplest solution to store a font is to store all the glyphs that compose it in sequence: since the glyphs are all the same size, it’s easy to index the one you want.

To encode the characters, we will use an 8-bit code that extends and slightly modifies standard ASCII.

In standard ASCII, codes from 0 to 31 are control codes (non-printable characters that are used to achieve other effects). We will use only 3:

  • Null (code 0, \0 in C strings): represents the string termination character;
  • Backspace (code 8, \b in C strings): we will use it to move the drawing position to the previous character position; this will allow us to add accents to the letters without having to store all the accented letters in the font;
  • Line feed (code 10, \n in C strings): used to move the drawing position to a new line.

We will use the 29 remaining control codes to store some graphic symbols.

Our default font will have a size of 8×16 pixels, so each character will occupy 16 bytes and the font will be stored in an array of 256 * 16 = 4096 bytes.

To correctly interpret the font in the drawing code, in addition to bitmap data, it is necessary to know the width and height of the glyphs. So we group all this information together in the Font structure we declare in GFX.h:

struct Font {
    uint8_t* data;
    uint8_t width;
    uint8_t height;
};

Then add to our graphics library the code to select the font to use. In the GFX class declaration add:

…
public:
…
void setFont(Font* f);

…

private:
…
Font* font;
uint16_t fontSize;
…

and in GFX.cpp:

void GFX::setFont(Font* f) {
    font = f;
    fontSize = ceil((double)font->width / 8) * font->height;
}

fontSize will hold the glyphs size in bytes.

The font will be defined like this:

// 8x16 pixel default font
const uint8_t defaultFontData[4096] PROGMEM = {
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 00 Null
…
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 20 Space
    0x00, 0x00, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x00, 0x10, 0x10, 0x00, 0x00, 0x00, // 21 !
    0x00, 0x00, 0x28, 0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 22 "
    0x00, 0x00, 0x28, 0x28, 0x28, 0xFE, 0x28, 0x28, 0x28, 0xFE, 0x28, 0x28, 0x28, 0x00, 0x00, 0x00, // 23 #
…
};

Font defaultFont = {
    .data = (uint8_t*)defaultFontData,
    .width = 8,
    .height = 16
};

For the complete code, download the DefaultFont.h file.

Note the use of the PROGMEM modifier, which tells the compiler to put the variable into flash instead of RAM. In this way we avoid occupying our precious RAM with data that does not need to be changed at runtime.

 

Drawing a character

Having the function to draw monochrome bitmaps and a font available, the function to draw a character on the screen is quite simple.

In the GFX class declaration add:

void drawChar(int16_t x, int16_t y, char character, uint8_t color);

and in GFX.cpp:

void GFX::drawChar(int16_t x, int16_t y, char character, uint8_t color) {
    uint8_t* charBitmap = font->data + fontSize * character;
    drawMonochromeBitmap(charBitmap, x, y, font->width, font->height, color);
}

 

Drawing a string

To draw a string, the only extra information we need to consider is the position of the cursor, which is where the next character will be drawn. Whenever we draw a character, the cursor position will be updated by increasing the horizontal position by the font width.

The only exceptions are these:

  • when you read the line feed code, the horizontal position of the cursor is set to 0 and the vertical position is increased by the font height;
  • when you read backspace code, the horizontal position of the cursor is decreased by the font width.

Here’s the code:

In the GFX class declaration add

void drawString(int16_t x, int16_t y, const char* string, uint8_t color);

and in GFX.cpp:

void GFX::drawString(int16_t x, int16_t y, const char* string, uint8_t color) {
    int16_t cx = 0, cy = 0; // Relative cursor position
    uint8_t c;
    int i = 0;
    while((c = *(string + i)) != 0) {
        if(c == 0x08) {
            // Backspace
            cx -= font->width;
        } else if(c == 0x0A) {
            // New line
            cx = 0;
            cy += font->height;
        } else {
            // Draw character
            drawChar(x + cx, y + cy, c, color);
            cx += font->width;
        }
        // Next character
        i++;
    }
}

 

Examples of use of drawString with the default font

// Draw "Hello, World! ☻" in red at position 100,50
gfx.drawString(100, 50, "Hello, world! \x03", 3);

// Draw "über" in yellow at position 30,20. Write the 'u', than \b
// go back one character, then \xCC draw the umlaut. The string must
// be divided because 'be' is a valid hex value and the escape sequence
// is not interpreted correctly.
gfx.drawString(30, 20, "u\b\xCC" "ber", 1);

Optimized scaling

To scale fonts, we could use nearest-neighbor resampling, but this scaling method introduce rough jagged edges. Some specialized algorithms have been developed to handle pixel-art graphics. These algorithms return smooth images but only work with whole scaling factors: 2x, 3x, etc.

So, we will implement only a 2x scaling using the Scale2x algorithm, adding this new function to the GFX class:

void drawMonochromeBitmap2x(uint8_t* bitmap, int16_t x, int16_t y, uint16_t width, uint16_t height, uint8_t color);

and add its definition in GFX.cpp:

void GFX::drawMonochromeBitmap2x(uint8_t* bitmap, int16_t x, int16_t y, uint16_t width, uint16_t height, uint8_t color) {
    // Draw bitmap with 2x scaling factor usign Scale2x algorithm
    int widthBytes = ceil((double)width / 8);
    for(int v=0; v<height; v++) {
        for(int u=0; u<width; u++) {
            int b = u >> 3;
            int bit = ~u & 7;
            uint8_t pixelValue = bitRead(bitmap[widthBytes * v + b], bit);
            uint8_t subpixel[4] = {pixelValue, pixelValue, pixelValue, pixelValue};
            // Pixel above the current one
            uint8_t valueA = 0;
            if(v - 1 >= 0)
                valueA = bitRead(bitmap[widthBytes * (v - 1) + b], bit);
            // Pixel below the current one
            uint8_t valueD = 0;
            if(v + 1 < height)
                valueD = bitRead(bitmap[widthBytes * (v + 1) + b], bit);
            // Pixel to the left of the current one
            int uC = u - 1;
            uint8_t valueC = 0;
            if(uC >= 0) {
                b = uC >> 3;
                bit = ~uC & 7;
                valueC = bitRead(bitmap[widthBytes * v + b], bit);
            }
            // Pixel to the right of the current one
            int uB = u + 1;
            uint8_t valueB = 0;
            if(uB < width) {
                b = uB >> 3;
                bit = ~uB & 7;
                valueB = bitRead(bitmap[widthBytes * v + b], bit);
            }
            if(valueC == valueA && valueC != valueD && valueA != valueB)
                subpixel[0] = valueA;
            if(valueA == valueB && valueA != valueC && valueB != valueD)
                subpixel[1] = valueB;
            if(valueC == valueD && valueB != valueD && valueA != valueC)
                subpixel[2] = valueC;
            if(valueD == valueB && valueA != valueB && valueC != valueD)
                subpixel[3] = valueD;
            
            if(ONSCREEN(x + 2 * u, y + 2 * v) && subpixel[0])
                drawPixel(x + 2 * u, y + 2 * v, color);
            if(ONSCREEN(x + 2 * u + 1, y + 2 * v) && subpixel[1])
                drawPixel(x + 2 * u + 1, y + 2 * v, color);
            if(ONSCREEN(x + 2 * u, y + 2 * v + 1) && subpixel[2])
                drawPixel(x + 2 * u, y + 2 * v + 1, color);
            if(ONSCREEN(x + 2 * u + 1, y + 2 * v + 1) && subpixel[3])
                drawPixel(x + 2 * u + 1, y + 2 * v + 1, color);
        }
    }
}

Now, we can add two new functions, drawChar2x and drawString2x, that make use of drawMonochromeBitmap2x. Add these declarations in the GFX class:

void drawChar2x(int16_t x, int16_t y, char character, uint8_t color);
void drawString2x(int16_t x, int16_t y, const char* string, uint8_t color);

and the definitions in GFX.cpp:

void GFX::drawChar2x(int16_t x, int16_t y, char character, uint8_t color) {
    uint8_t* charBitmap = font->data + fontSize * character;
    drawMonochromeBitmap2x(charBitmap, x, y, font->width, font->height, color);
}

void GFX::drawString2x(int16_t x, int16_t y, const char* string, uint8_t color) {
    int16_t cx = 0, cy = 0; // Relative cursor position
    uint8_t c;
    int i = 0;
    while((c = *(string + i)) != 0) {
        if(c == 0x08) {
            // Backspace
            cx -= 2 * font->width;
        } else if(c == 0x0A) {
            // New line
            cx = 0;
            cy += 2 * font->height;
        } else {
            // Draw character
            drawChar2x(x + cx, y + cy, c, color);
            cx += 2 * font->width;
        }
        // Next character
        i++;
    }
}

Enter the Matrix

Now that we have everything we need to write text on the screen, one cool thing we can do is reproducing the iconic Matrix’s green code “rain”.

Try this code:

/* main.cpp */

#include <Arduino.h>
#include "GFX.h"
#include "DefaultFont.h"

#define CHARDROPS_COUNT 15

// Point - this structure is used to store the current position
// of a "char drop".
struct Point {
    uint8_t x;
    uint8_t y;
};

// Cell - the screen is a grid of these structures; every cell
// contains the character shown on the screen and how many frames
// ago it was drawn.
struct Cell {
    uint8_t character;
    int age;
};

GFX gfx;
Point chardrops[CHARDROPS_COUNT];
Cell screen[40][30];

// randomChar - generate a random printable characted (space excluded)
uint8_t randomChar() {
    uint8_t rc;
    do {
        rc = random(0, 256);
    } while(rc == 0x00 || rc == 0x08 || rc == 0x0A || rc == 0x20);
    return rc;
}

void setup() {
    // Start graphics library and set default font
    gfx.begin();
    gfx.setFont(&defaultFont);
    
    // Randomly generate char drops starting position
    for(int i=0; i<CHARDROPS_COUNT; i++) {
        chardrops[i].x = random(0, 40);
        chardrops[i].y = random(0, 20);
    }
}

void loop() {
    for(int i=0; i<CHARDROPS_COUNT; i++) {
        // Write a new char at drop positions
        screen[chardrops[i].x][chardrops[i].y].character = randomChar();
        screen[chardrops[i].x][chardrops[i].y].age = 0;

        // Update drop position. If the end of the screen has been
        // reached, restart from the upper side.
        chardrops[i].y++;
        if(chardrops[i].y == 30) {
            chardrops[i].x = random(0, 40);
            chardrops[i].y = 0;
        }
    }

    // Clear screen
    gfx.fillScreen(15);

    // Draw char drops. Use white for current drop positions,
    // light green for the most recent part of the trail and
    // dark green for the rest.
    for(int x=0; x<40; x++) {
        for(int y=0; y<30; y++) {
            uint8_t color;
            if(screen[x][y].age == 0)
                color = 0; // White
            else if(screen[x][y].age <= 8)
                color = 8; // Light green
            else if(screen[x][y].age <= 30)
                color = 9; // Dark green
            else
                color = 15; // Black, the character can't be seen.
            gfx.drawChar(8*x, 16*y, screen[x][y].character, color);
        }
    }

    // Draw "THE MATRIX" with a black border (made by drawing the
    // string in black with an offset of 2px in each direction)
    gfx.drawString2x(82, 216, "THE MATRIX", 15);
    gfx.drawString2x(78, 216, "THE MATRIX", 15);
    gfx.drawString2x(80, 214, "THE MATRIX", 15);
    gfx.drawString2x(80, 218, "THE MATRIX", 15);
    gfx.drawString2x(80, 216, "THE MATRIX", 0);

    // Update age of drops
    for(int x=0; x<40; x++) {
        for(int y=0; y<30; y++) {
            screen[x][y].age++;
        }
    }

    // Update screen
    gfx.update();
    delay(30);
}

If everything works correctly, you will see something like this:

You can find the complete code of this post here.

 

In the next post…

In the next post we will optimize the graphics library to get better frame rate!


0 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

By continuing to browse this Website, you consent to the use of cookies. More information

This Website uses:

By continuing to browse this Website without changing the cookies settings of your browser or by clicking "Accept" below, you consent to the use of cookies.

Close