ates.dev

Using Bitmaps in Dweets

Posted on Dec 30, 2024

This is a breakdown of one of my older dweets, introducing a dweet rendering technique and a few general JavaScript golfing techniques.

It's a follow-up to:

  1. A Short Introduction to Dwitter and JavaScript Golfing
  2. Dweeting Outside the Box

Suppose we want to render a specific bitmap image in a Dweet. As an example, I'll nostalgically pick the mouse pointer icon of the TOS operating system on the Atari ST:

The Atari ST mouse pointer

It's a 16x16 icon, but the bounding box of the black pixels is 8x14, so we can ignore pixel information that falls outside.

The raw binary data to encode this information looks like this:

10000000
11000000
11100000
11110000
11111000
11111100
11111110
11111111
11111000
11011000
10001100
00001100
00000110
00000110

If you squint, you should be able to make out the mouse pointer.

In its raw form these ones and zeroes would take up 8 * 14 = 112 characters, not leaving much room in a 140-character dweet to incorporate code to actually render the pixels.

Let's start with packing each of the rows into a byte. I'll prefix each row with 0b to turn it into a binary number literal:

const bytes = [
  0b10000000,
  0b11000000,
  0b11100000,
  0b11110000,
  0b11111000,
  0b11111100,
  0b11111110,
  0b11111111,
  0b11111000,
  0b11011000,
  0b10001100,
  0b00001100,
  0b00000110,
  0b00000110,
];

This is equivalent to:

const bytes = [128, 192, 224, 240, 248, 252, 254, 255, 248, 216, 140, 12, 6, 6];

We can now use String.fromCharCode() to turn each number into characters and then concatenate them:

const bytes = [128, 192, 224, 240, 248, 252, 254, 255, 248, 216, 140, 12, 6, 6];
const encoded = bytes.map((c) => String.fromCharCode(c)).join('');

This is what we get:

const encoded = '\x80ÀàðøüþÿøØ\x8C\f\x06\x06';

Note the extra overhead of the escaped characters in \x## form. Instead of single characters, they're all encoded as 4 characters. There's also the \f form feed character.

Depending on the size and content of the bitmap, you may get none, but you could also get a lot of these escape sequences. They take up precious space.

And when the bitmap size is wider than 8 pixels, you may venture into the much more verbose Unicode code point sequences, \u####.

We can get around this by adding an offset to the character codes to put them within 0x0000 through 0xFFFF. Characters within the Basic Multilingual Plane (BMP) don't require escaping, except:

Values Designation
0 through 31 C0 controls
127 DEL (delete)
128 through 159 C1 controls

Keep in mind that fromCharCode() only works with BMP. We can't go beyond 16 bits (or bitmap columns). There's fromCodePoint() to access characters past BMP, but I won't get into that.

So, if we add 160 to the bit-packed value, we'll make sure we stay within the range that doesn't require escaping:

const bytes = [128, 192, 224, 240, 248, 252, 254, 255, 248, 216, 140, 12, 6, 6];
const encoded = bytes.map((c) => String.fromCharCode(c + 160)).join('');

This is what we get:

const encoded = 'ĠŠƀƐƘƜƞƟƘŸĬ¬¦¦';

Nothing escaped!

Let's render our bitmap using 8x8 squares.

We will pluck the character corresponding to the whole encoded row with charCodeAt(Y) and then test the bit corresponding to pixel at X by AND'ing with a bitmask we obtain by left-shifting 1 by X places.

In verbose form, that's (encoded.charCodeAt(Y) - 160) & (1 << X), but we can exploit operator precedence at the expense of human readability to make it much shorter: encoded.charCodeAt(Y)-160&1<<X:

c.width|=0
for(Y=14;Y--;)for(X=8;X--;)'ĠŠƀƐƘƜƞƟƘŸĬ¬¦¦'.charCodeAt(Y)-160&1<<X&&x.fillRect(X*8,Y*8,8,8)

That didn't quite work as expected because the X axis loop is reversed to save space. Instead of reversing that loop or replacing the bit accessor i with 7-i and wasting space, we can simply flip our image data before encoding.

By doing more preparation outside of the dweet, we save space within.

Mouse pointer icon, flipped on the X axis and encoded again:

const bytes = [
  0b00000001,
  0b00000011,
  0b00000111,
  0b00001111,
  0b00011111,
  0b00111111,
  0b01111111,
  0b11111111,
  0b00011111,
  0b00011011,
  0b00110001,
  0b00110000,
  0b01100000,
  0b01100000,
];
const encoded = bytes.map((c) => String.fromCharCode(c + 160)).join('');

We get:

encoded = '¡£§¯¿ßğƟ¿»ÑÐĀĀ';

And then we can render the icon facing the right way with no extra code:

c.width|=0
for(Y=14;Y--;)for(X=8;X--;)'¡£§¯¿ßğƟ¿»ÑÐĀĀ'.charCodeAt(Y)-160&1<<X&&x.fillRect(X*8,Y*8,8,8)

If we can add some offset, 160, to get us past the C1 block, what prevents us from using a power of 2 as the offset? Since we're doing binary masking to test bits, we're already ignoring any bits that fall outside our most significant bit. Let's go up to next power of 2 that's larger than 160: 256.

const bytes = [
  0b00000001,
  0b00000011,
  0b00000111,
  0b00001111,
  0b00011111,
  0b00111111,
  0b01111111,
  0b11111111,
  0b00011111,
  0b00011011,
  0b00110001,
  0b00110000,
  0b01100000,
  0b01100000,
];
const encoded = bytes.map((c) => String.fromCharCode(c + 256)).join('');

We get:

encoded = 'āăćďğĿſǿğěıİŠŠ';

And we gloriously save 4 characters by omitting the -256 subtraction:

c.width|=0
for(Y=14;Y--;)for(X=8;X--;)'āăćďğĿſǿğěıİŠŠ'.charCodeAt(Y)&1<<X&&x.fillRect(X*8,Y*8,8,8)

A JavaScript golfing technique that can sometimes save space: Joining two nested loops into one.

We can loop over all 112 pixels and then compute the X and Y values.

For Y, the verbose way would be dividing the iterator i with the number of columns and flooring the value: Math.floor(i/8). We can also exploit binary operators to coerce the result into an integer: i/8|0. However, since in this case we're lucky enough to have exactly 8 columns, we can simply bit-shift the value by 3 bits: i>>3.

For X, a modulo would work: X=i%8. However, since 8 is a power of two, we can use a binary AND with 7 to the same effect: X=i&7. It's symmetrical with our use of bit shifting for Y and it also truncates the result in case i were to be not an integer — something modulo doesn't provide.

When you have creative freedom in picking dimensions in a dweet, sticking to powers of 2 can yield savings.

Here's the collapsed loop version:

c.width|=0
for(i=112;i--;'āăćďğĿſǿğěıİŠŠ'.charCodeAt(Y=i>>3)&1<<X&&x.fillRect(X*8,Y*8,8,8))X=i&7

Note that we're now using the "final expression" slot of the for loop to do the rendering. This slot doesn't have to be only used for incrementing/decrementing a loop variable. We've left the X=i&7 assignment within the loop because we then don't have to create an empty loop body with a ; character and we don't need to use a , operator within the final expression.

Also note a common trick where we can store the value of an expression while we use it for the first time, as in charCodeAt(Y=i>>3).

Just saved 2 characters, but it's worth it!

Since we still have a whopping 44 characters until the 140 character limit, we can throw in some fun.

Mouse hijack!

c.width|=0
for(i=112;i--;'āăćďğĿſǿğěıİŠŠ'.charCodeAt(Y=i>>3)&1<<X&&x.fillRect(X*8+(S(t*3.1)**5+1)*960,Y*8+(C(t*5.3)**7+1)*540,8,8))X=i&7

Some final tricks that are used in the above:

  1. Add 1 to the sine and cosine values, then divide by 2 to let them go between 0 and 1 instead of -1 and 1. Though, in the above, I'm not dividing by 2 but multiplying by half the extents of the canvas to save space.
  2. Raise values to powers less then 1 to dampen and more than 1 to excite motion. I obtain the jolty motion by the **5 and **7.
  3. Multiply angles with primes or "weird" values to prevent the motion to have a short loop cycle. The curves will take a long time converging back on their starting positions thanks to the *3.1 and *5.3.

Discourse

Comments, questions, corrections? Please drop them on Bluesky!