gfx.alphaCollision is broken when height > 1px

Hi all!

Reporting a bug I found while debugging for someone on the Playdate Squad Discord (thread here).

I found that the gfx.checkAlphaCollision seems to return wrong values when given a sprite more than 1px tall (in height). The same did not apply to sprites more than 1px in width.

Here you can see a sprite that is 1 pixel large, with the expected behavior:

pixel-example

(Screen inverts on collision)

Here's another example with a horizontal line, also working:

example-hline

And, here are two instances of the bug. Both are more than 1px tall vertically.

example-vline

example-player

In the meantime I wrote a fix which bypasses the bug, by splitting the method to only check horizontal slices of an image. There's definitely a performance loss involved but it can be useful if someone needs it:

Workaround method
---comment
---@param image1 _Image
---@param x1 integer
---@param y1 integer
---@param isFlip1 integer
---@param image2 _Image
---@param x2 integer
---@param y2 integer
---@param isFlip2 integer
local function _alphaCollisionFixed(image1, x1, y1, isFlip1, image2, x2, y2, isFlip2)
    -- Alpha collision check individually for each line on y-axis
    local w1, h1 = image1:getSize()
    local w2, h2 = image2:getSize()

    local imageToCheck = gfx.image.new(w2, 1)

    local numLines = math.abs(y2 + h2 - (y1 + h1))
    local isAlphaCollision = false
    for i = 0, numLines - 1 do
        -- Draw sub-image
        gfx.pushContext(imageToCheck)
        gfx.clear(gfx.kColorClear)
        image2:draw(0, -i)
        gfx.popContext()

        isAlphaCollision = isAlphaCollision or
            gfx.checkAlphaCollision(image1, x1, y1, isFlip1, imageToCheck, x2, y2 + i, isFlip2)
    end

    return isAlphaCollision
end

For reproducing the bug, here is the main.lua:

main.lua
import "CoreLibs/object"
import "CoreLibs/graphics"
import "CoreLibs/sprites"
import "CoreLibs/timer"

local w = playdate.display.getWidth()
local h = playdate.display.getHeight()

local gfx <const> = playdate.graphics

local function drawMine(x, y, s, a)
    local am = a * math.pi / 180
    gfx.fillPolygon(x + s * math.cos(am), y + s * math.sin(am), x + s * math.cos(am + math.pi * 2 / 5),
        y + s * math.sin(am + math.pi * 2 / 5), x + s * math.cos(am + math.pi * 2 * 2 / 5),
        y + s * math.sin(am + math.pi * 2 * 2 / 5), x + s * math.cos(am + math.pi * 3 * 2 / 5),
        y + s * math.sin(am + math.pi * 3 * 2 / 5), x + s * math.cos(am + math.pi * 4 * 2 / 5),
        y + s * math.sin(am + math.pi * 4 * 2 / 5))
end

local function gen_rect_field(n, r)
    local gen_a = {}
    local gen_r = {}
    local gen_p = {}
    for i = 1, n do
        gen_a[i] = math.random(0, 360)
        gen_r[i] = r * r * r - math.random(1, r) * math.random(1, r) * math.random(1, r)
        gen_p[i] = math.random(0, 360)
        if gen_r[i] < 10 or gen_r[i] > r * r * r - 10 then
            gen_r[i] = r * r * r - 10
        end
    end
    return gen_a, gen_r, gen_p
end

local function d2r(d)
    return math.pi * d / 180
end

local mine_a, mine_r, mine_p = gen_rect_field(100, 6)

local function render_minefield(gx, gy, ga)
    local c = math.cos(d2r(ga))
    local s = math.sin(d2r(ga))
    local xmod = c * gx - s * gy
    local ymod = s * gx + c * gy
    for i = 1, 100 do
        local x = math.cos(d2r(mine_a[i] + ga)) * mine_r[i] + xmod + w / 2
        local y = math.sin(d2r(mine_a[i] + ga)) * mine_r[i] + ymod + h / 2

        --local x = math.cos(d2r(mine_a[i]) * mine_r[i] + math.cos(d2r(ga)) * gx + math.sin(d2r(ga)) * gy
        --local y = math.sin(d2r(mine_a[i] + ga)) * mine_r[i] + w / 2 + math.sin(d2r(ga)) * gx + math.cos(d2r(ga)) * gy
        if x + 10 > 0 and x - 10 < w and y + 10 > 0 and y - 10 < h then
            drawMine(x, y, 10, mine_p[i] + ga)
        end
    end
end

local imageBackground <const> = gfx.image.new(w, h, gfx.kColorClear)
local imagePlayer <const> = gfx.image.new(7, 1.618 * 7, gfx.kColorWhite)
local imagePixel <const> = gfx.image.new(1, 1, gfx.kColorWhite)
local imageHLine <const> = gfx.image.new(4, 1, gfx.kColorWhite)
local imageVLine <const> = gfx.image.new(1, 4, gfx.kColorWhite)

local crankPosition = 0
local xCrankPosition, yCrankPosition
local xMinefield = 0
local yMinefield = 0

---comment
---@param image1 _Image
---@param x1 integer
---@param y1 integer
---@param isFlip1 integer
---@param image2 _Image
---@param x2 integer
---@param y2 integer
---@param isFlip2 integer
local function _alphaCollisionFixed(image1, x1, y1, isFlip1, image2, x2, y2, isFlip2)
    -- Alpha collision check individually for each line on y-axis
    local w1, h1 = image1:getSize()
    local w2, h2 = image2:getSize()

    local imageToCheck = gfx.image.new(w2, 1)

    local numLines = math.abs(y2 + h2 - (y1 + h1))
    local isAlphaCollision = false
    for i = 0, numLines - 1 do
        -- Draw sub-image
        gfx.pushContext(imageToCheck)
        gfx.clear(gfx.kColorClear)
        image2:draw(0, -i)
        gfx.popContext()

        isAlphaCollision = isAlphaCollision or
            gfx.checkAlphaCollision(image1, x1, y1, isFlip1, imageToCheck, x2, y2 + i, isFlip2)
    end

    return isAlphaCollision
end

function playdate.update()
    crankPosition = playdate.getCrankPosition()
    xCrankPosition = math.cos(crankPosition * math.pi / 180)
    yCrankPosition = math.sin(crankPosition * math.pi / 180)
    yMinefield += xCrankPosition
    xMinefield += yCrankPosition

    -- render the bg of the screen

    gfx.pushContext(imageBackground)
    gfx.clear(gfx.kColorClear)
    render_minefield(xMinefield, yMinefield, crankPosition)
    gfx.popContext()
    gfx.clear(gfx.kColorBlack)

    imageBackground:invertedImage():draw(0, 0)

    -- Render player, check alpha collisions

    local xPlayer, yPlayer = math.floor((w - 7) / 2), math.floor((h - 7 * 1.618) / 2)

    --------------------------------------------------------
    --- PARAMS TO CUSTOMIZE
    --------------------------------------------------------

    -- local imageToDraw = imagePixel -- Working
    -- local imageToDraw = imageHLine -- Working
    -- local imageToDraw = imageVLine -- Broken
    local imageToDraw = imagePlayer -- Broken

    -- local fnAlphaCollision = _alphaCollisionFixed -- Working
    local fnAlphaCollision = gfx.checkAlphaCollision -- Broken for height > 1

    --------------------------------------------------------
    --------------------------------------------------------

    imageToDraw:draw(xPlayer, yPlayer)

    if fnAlphaCollision(imageBackground, 0, 0, 0, imageToDraw, xPlayer, yPlayer, 0) then
        playdate.display.setInverted(true)
    else
        playdate.display.setInverted(false)
    end
end

You can easily swap out the different example images to test the behavior. I also included the option to test with the alpha-collisions-fix work-around method.

Happy debugging! :smiley:

P.S. the replacement method is probably not super rigorous... I just noticed for example, that the width to take into consideration should be the width-overlap between image1 and image2, not just the image2 width.

Would take some minimal changes to fix that up, but I'll leave it for now in case anyone wants a take-home exercise :slight_smile: