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.

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