Grayscale 201Prerequisites: Grayscale 101, z80 Assembly 101Before we move on to implementation specifics, there's one more global grayscale quality improvement that you should be aware of. It turns out that the LCD's update direction is left-to-right followed by top-to-bottom, and artifacts appear when your LCD update routine's write position intersects with the LCD's update position. So, to minimize artifacts, your LCD update routine should also go left-to-right followed by top-to-bottom. This unfortunately means more LCD port writes to move the write position around, but unless you're desparate to squeeze out more speed, it's still recommended.
Now that that's out of the way, let's talk more about how to actually implement grayscale in a program. I have identified three important pieces:
- Image representation/drawing
- Update routine
- Update timing
Image representation/drawingTo actually represent grayscale imagery, you're going to need more than 1bpp (bit per pixel). To be precise, you'll need ?log
2(
shades)?bpp. That comes out to 2bpp for 3- to 4-level grayscale, 3bpp for 5- to 8-level grayscale, 4bpp for 9- to 16-level grayscale, etc. There are two common approaches to storing grayscale image buffers: as separate 1bpp buffers or as one byte-interleaved buffer. The former has the advantages that monochrome drawing routines can be simply be used on each buffer instead of needing dedicated grayscale drawing routines and that the buffers don't need to occupy a contiguous section of memory. The latter has the advantage that routines designed for it, whether drawing or LCD update, are generally faster. But since the way in which you represent grayscale imagery isn't actually integral to how it's displayed on the LCD, we'll end this piece here.
Update routineThe update routine is almost certainly the hardest piece to create, especially at higher levels of grayscale. Specifically, dithering is what makes it hard. A good dithering pattern of course needs to be good at temporal dithering, so each pixel is black/white for the correct percentage of updates. But perhaps harder, especially in combination with the former, is that it should also be good at spatial dithering, so each individual update most accurately approximates the grayscale image.
For 3- and 4-level grayscale, the optimal dithering patterns are rather trivially obvious. Assuming an interleaved buffer pointer in
hl with MSB bytes coming immediately after LSB bytes, the inner loop to display dithered 3-level grayscale should look something like the following:
loop:
loop:
rrc c ; rotate mask with pattern %10101010
ld a,(hl) ; read a byte of LSB's
inc hl
and c ; select half of them
or (hl) ; read a byte of MSB's; set bit -> black
inc hl
out ($11),a ; output the dithered byte
djnz loop
And, for 4-level grayscale should look something like the following:
loop:
rr c ; rotate mask with pattern %100100100
push af
ld a,(hl) ; read a byte of LSB's
inc hl
xor (hl) ; mux byte of LSB's with byte of MSB's
and c
xor (hl)
inc hl
out ($11),a ; output the dithered byte
pop af
djnz loop
But past that, the visually best dithering patterns aren't so obvious, and neither are the ways to generate them efficiently in code. Perhaps the best attempt to go further beyond is by the same man who helped out in Grayscale 101. thepenguin77's
allGray is an open source demo program that showcases all levels of grayscale from the ultra trivial of 1 up to the far less trivial 8. There are also some somewhat hidden works by tr1p1ea, which simulate
7-,
8-, and [url=http://tr1p1ea.net/files/downloads/ANLVL.8XP]9-level grayscale, but they're unfortunately compiled code only. I defer to both of these individual's work on this matter because they have put in more work into actual implementation, while I've worked more on theory. Speaking of which, I theorycrafted up this chart of what seemed to me to be the best dithering patterns for all levels of grayscale from 1 to 8.
I could certainly believe that there are better patterns. For a number of levels, I provided multiple options becuase I'm not sure which is better visually and/or implementation-wise. In particular, the asterisk in the black color for 7-level grayscale indicates that I don't even think the masking logic for the previous shades works out to produce black. I would wholeheartedly welcome feedback on these!
As an interesting side note, when putting in the actual grays next to the dithered grays for comparison, I noticed that a dither pattern in which
x percent of pixels are black does not actually produce
x% gray. Upon researching this and other topics, I stumbled across
this dithering article, in which appendix 1 is espeically of interest. It corroborates this finding and, furthermore, suggests a formula for how to calculate between the two figures. Combined with some individual's past perceptions that grayscale on a physical calculator does not evenly distribute the shades of gray, this is a topic that I believe requires more research. If one wanted more shade-accurate 4-level grayscale, for instance, perhaps that could be better achieved by selectively using 2 shades of gray from a higher level of grayscale.
Update timingThis topic was pretty well covered in thepenguin77's
perfect grayscale tutorial, but I'll repeat the important results. You'll want the grayscale update frequency to be tunable by the user, but it should be roughly around 60Hz. A good way to achieve a roughly 60Hz timer is with the
crystal timers on the 83+SE/84+(SE). Write
03h to a timer's loop control port, write
40h to a timer's on/off port for the best base frequency of 10922.667Hz, and write the counter value (start with a value of around 182) to the timer's counter port. Each interrupt, make sure to rewrite
03h to a timer's loop control port! For a sample interrupt handler installer, consider checking out
this WikiTI page, although make sure to use the crystal timer as an interrupt source instead of the hardware timer!