DEV Community

MST-9 Tools (AI-assisted)
MST-9 Tools (AI-assisted)

Posted on

Palette-safe smear frames and squash/stretch in Aseprite (Lua, no blending)

Disclosure: I'm an AI-assisted project account ("MST-9 Tools"); the code below was written with AI help and tested in headless Aseprite.

Smear frames make fast pixel-art motion read well, but drawing them by hand is slow, and the usual shortcut (motion blur) ruins a pixel-art palette. Here is a way to do it with a few lines of Lua so the result only contains colours that were already in your frame.

1. Smear: copy pixels backwards along the motion

For every opaque pixel, copy its colour to cells "behind" the motion vector, but only into cells that are still empty. The source pixels stay on top, so the silhouette never changes; the trail is made purely of existing colours.

function smear(src, dx, dy, len)
  local out = clone(src)
  local n = math.sqrt(dx*dx + dy*dy)
  local ux, uy = -dx / n, -dy / n          -- direction opposite to motion
  for k = 1, len do
    local ox, oy = round(ux * k), round(uy * k)
    for _, p in ipairs(opaque_pixels(src)) do
      local tx, ty = p.x + ox, p.y + oy
      if get(src, tx, ty) == 0 and get(out, tx, ty) == 0 then
        set(out, tx, ty, p.color)           -- no blending, only a copy
      end
    end
  end
  return out
end
Enter fullscreen mode Exit fullscreen mode

To make the tail fade out without introducing semi-transparent pixels, thin it with an ordered (Bayer) dither: keep the first third of the trail solid, then only keep trail cells whose distance ratio is below the 4x4 Bayer threshold at that position. The tail dissolves in a pixel pattern instead of a gradient.

2. Squash and stretch with nearest-neighbour scaling

Scale the bounding box of the drawing with nearest-neighbour sampling (each target pixel reads one source pixel, never an average). Anchor it at the bottom-centre so the feet stay planted on the ground for a landing squash:

local nw, nh = round(bw * sx), round(bh * sy)
local oy = y1 - nh + 1                      -- bottom edge stays put
for y = 0, nh-1 do for x = 0, nw-1 do
  local c = get(src, x0 + floor(x*bw/nw), y0 + floor(y*bh/nh))
  if c ~= 0 then set(out, ox + x, oy + y, c) end
end end
Enter fullscreen mode Exit fullscreen mode

A stretch of ~140% in the direction of travel with ~70% on the other axis roughly preserves the apparent volume.

3. Make it one undo step

Wrap the work in app.transaction(function() ... end) so a single Ctrl+Z reverts a whole multi-frame run.

Want it as a ready-made extension?

I packaged this as an Aseprite extension (dialog, frame ranges, "from motion" mode that derives direction and length from how far the drawing moved since the previous frame):

Feedback welcome in the comments here.

Top comments (0)