Using Bitmaps in Dweets
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:
The subject
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:
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.
Encoding, with a problem
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####
.
More efficient encoding
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!
Initial render
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.
More preparation
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)
Wait a minute...
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)
Loop collapse
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!
When you have extra space
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:
- Add
1
to the sine and cosine values, then divide by2
to let them go between0
and1
instead of-1
and1
. Though, in the above, I'm not dividing by2
but multiplying by half the extents of the canvas to save space. - 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
. - 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
.