Building/manipulating grayscale images (and general offscreen drawing) in lua?

Just want to make sure I'm not missing a trick before I do something moderately painstaking:

I was playing the Analogue Pocket's Spacewar! reproduction and it got me thinking the PDP-1's ultra-high-persistence phosphor -- like 2 seconds to fully fade -- might actually look cool on the Playdate. I did a bit of testing and it's surprisingly legible.

The test version is slow though, because it reassembles the whole phosphor trail from scratch every frame. What I'd hoped to do is maintain an 8-bit half-res grayscale bitmap of the whole screen to represent the phosphors, drawing copies of my sprites to the phosphor bitmap and fading that bitmap a little bit every frame, then dithering that out to screen. If I'm exceedingly clever, I might be able to manage dirty rects just for the places that the trails cross the dither pattern thresholds, but the other draw tasks are simple enough and the Playdate draws fast enough that I could probably spit the whole thing out every (or every other) frame.

Trouble is, while the Playdate APIs pass images through grayscale bitmap manipulation, they never expose those bitmaps to the caller, so I can't use successive passes of fadedImage() and blendWithImage() to assemble and maintain that phosphor map. So I think I need to just keep my own 200x120 table of bytes and update/render it out myself? As far as I can tell the only way to read/write images to/from my table is with image:drawPixel(x,y) and image:sample(x,y), which seems... well, it seems like a lot of calls, but I guess they're fast. Won't be too miserable as long as drawPixel treats dither patterns as the "current color", but it would certainly be nice to be able to treat bitmaps as tables and vice versa now and then.

This all seems sensible enough and somewhat tractable, but it feels a little bit too difficult for things that I'd think of as fairly basic image manipulation. Is there a better way that I'm missing?

(I saw librif, but that doesn't look like you can draw to the grayscale bitmaps, just render existing ones out with transformations.)

In case you haven't seen the PDP-1's awesome display, this is a real nice demo:

Cool video! Interesting idea.

How about this... given that dither patterns impose a limit on the number of perceivable steps between on/off, don't fight it and simply store a stack of, say, 8/16/32 layers (assuming you'll use Bayer8x8).

You'd draw the current image to the top layer and then every so often rotate the stack. Each image could be drawn with a different dither pattern/"opacity".

I use this method in my games but with only a couple of layers. The benefit is that you don't have to do any per-pixel work.

If I'm off target, feel free to shoot me down! :rocket:

1 Like

Huh. That's a pretty good idea. I had already considered doing the phosphor update at half-rate, but this would effectively reduce the frame rate of the dimming effect even more, and so several frames of animation would fade out each glowmap update. Making the glow change slowly would detract from one of the cool things in my prototype (attached below -- it's just my PONG-like, which now has 'XL' and 'XXL' phosphor trail options), where twitchy paddle movements gain a sort of instant-replay effect. But I wasn't going to do something that fast-moving anyway.

I don't actually need to match the number of layers to the number of dither patterns available -- there's nothing saying I can't repeat patterns across layers, and 8x8 is probably too big to work well with smaller details. And I don't think the phosphor falloff is linear anyway.

If the Playdate will actually let me get away with laying down 32 layers of phosphor memory (I guess I can if I'm smart about what I mark dirty) then even if I wanted the PDP-1's lackadaisical 2-3 seconds of fade (and I don't know for sure that I do) that gets me 11-16 FPS in the glow refresh. It might be subtle enough to get by. I'll try it out.

ball.pdx.zip (39.3 KB)

1 Like

I was able to make your cel-stack approach work and perform pretty well (and look great on the Playdate), but a fair amount of cleverness was required to keep performance up with multiple sources and long trails -- we're talking like 100-200 frames of phosphor memory, and the Playdate gets irritable if there are more than a couple full-screen sprites overlaid on each other.

  • To minimize draw area size, each sprite had to own its own stack of afterimages.

  • Since the fade steps are slower than once per frame, each sprite has multiple update frames stamped into it, and so the top afterimage in the stack has to get stretched as the parent sprite moves. For about two seconds of phosphor memory, this comes out to 16 afterimage sprites per parent sprite, with each afterimage having been stamped with up to 6 sprite frames.

  • To avoid lurching every time the trails dimmed (every 6 frames), I'd phase the dimming across the length of the trail, doing 1/6 of them every frame.

This worked, but performance was substantially impacted by especially large or numerous sprites. And it would've required even more cleverness and code hygiene to manage the lifecycle of afterimages attached to short-lived sprites like bullets.

--

On a separate channel @jmibo suggested how I could achieve what I was initially looking for, which was to maintain a single full-screen afterimage bitmap that is continuously added to and dimmed. Consider the 8x8 pattern dither matrix:

{   { 0,32, 8,40, 2,34,10,42},
    {48,16,56,24,50,18,58,26},
    {12,44, 4,36,14,46, 6,38},
    {60,28,52,20,62,30,54,22},
    { 3,35,11,43, 1,33, 9,41},
    {51,19,59,27,49,17,57,25},
    {15,47, 7,39,13,45, 5,37},
    {63,31,55,23,61,29,53,21} }

In the traditional dither implementation, you compare the brightness of each pixel in your image to the value to the value in the corresponding cell of this matrix (divided by 64). If you were animating a continuously dimming square, this matrix would reflect the sequence of pixels going black each step. So you can imagine this as a set of 64 8x8 patterns that each just has one black pixel, that get overlaid sequentially.

So what if you loop through this sequence of overlays continuously adding one black pixel, while also continuously stamping white pixels from your glowing sprites? The pattern isn't explicitly designed to be looped so there's some artifacting (note that 63 and 0 are neighbors), but it looks fine on the device. Here's a gif of the results -- I've added some squares in the right corners to show the artifacts from looping through the Bayer patterns in this way. It looks weird on desktop, but the Playdate is small enough it's not an issue. The slow cadence of the fade steps is also less obvious on-device. This is fast.

phosphorglow

The nice thing here is that there's just one afterimage bitmap and it doesn't care how many sprites contribute to it. It also doesn't matter how long the trails are -- all the requisite bits were reserved when the image was created. The stenciled-stamp and fade operations are really cheap, because (a) they're simple and (b) this is exactly the sort of work that's well optimized on the Playdate.

Here's the demo pdx if you want to see it on device. It's Spacewar without the war part -- no shots. Not much fun, but it was just supposed to be a drawing demo anyway. Crank/A for the right ship, d-pad up/down/right for the left ship. The ship-rotation code is very slow and could be easily improved.

phosphorglow.pdx.zip (49.9 KB)

Here's the code that stamps to and fades the afterimage sprite. "shutters" and "initialFadeSet" are each sets of 64 8x8 patterns, the former being the set of single-black-pixels described above, and 'initialFadeSet' being the last 48 pixels to have dimmed at each stage of that shutter pattern (the 25% black pattern in the lower right corner of the gif).

function PhosphorScreen:stamp(stampingSprite)
    local stampImage = stampingSprite:getImage()
    gfx.lockFocus(self.workingImage)
    local boundsRect = stampingSprite:getBoundsRect()
    gfx.setImageDrawMode(gfx.kDrawModeBlackTransparent)
    gfx.setStencilPattern(self.initialFadeSet[self.currentStep])
    stampImage:draw(boundsRect.x, boundsRect.y)
    gfx.setImageDrawMode(gfx.kDrawModeCopy)
    gfx.clearStencil()
    gfx.unlockFocus()
end

function PhosphorScreen:fade()
    --increment and cap to 1-64
    self.currentStep = self.currentStep % 64
    self.currentStep += 1
    gfx.lockFocus(self.workingImage)
    gfx.setStencilPattern(self.shutters[self.currentStep])
    gfx.setColor(gfx.kColorBlack)
    gfx.fillRect(0,0,400,240)
    gfx.clearStencil()
    gfx.unlockFocus()
    self:markDirty()
end
3 Likes

Result! Very cool. Looks great