About changing an image mask

this is quite strange , i make a funciton to keep changing a mask_img .
then in my sprite:update()
seconds_ring_sprite:setImage(mask_img) -- this work
but , if i used the mask_img as a mask image , like this:
seconds_ring_img:setMaskImage(mask_img)
seconds_ring_sprite:setImage(seconds_ring_img) -- this not work , when i use "highlight screen updates" option within the simulator , i notice that even that is not in update box . then i try to add

seconds_ring_sprite:markDirty() -- still notwork .
but when i add:
sprite.setAlwaysRedraw(true) in my main.lua , then the drawing is correct (with the mask keep changing effect)

i dont know why , can i just change the imageMask , then make a animation effect ?

the function keep changing the mask_img is like this:
function ArcSprite:get_mask(radius, line_width, new_angle)
local radius = radius or 80
local line_width = line_width or 40
local new_angle = new_angle or 0

local mask_img = gfx.image.new(ArcBox_Width, ArcBox_Height)
gfx.pushContext(mask_img)
    gfx.setColor(gfx.kColorBlack)
gfx.fillRect(0, 0, mask_img:getSize())
    gfx.setColor(gfx.kColorWhite)
    gfx.setLineWidth(line_width)
    gfx.drawArc(ArcBox_Width/2, ArcBox_Height/2, radius, new_angle, 360)
gfx.popContext()
return mask_img

end

It's not totally clear to me what effect you're trying to accomplish or how you're working with the sprite and mask image. Here are two examples based on your description. Are either of them what you're trying to accomplish? Or have I misunderstood?

The first applies a shrinking mask to the sprite's image (a smiley face).

shrinkmask

import 'CoreLibs/graphics'

local gfx <const> = playdate.graphics

local function get_mask(radius, line_width, new_angle)
	local radius = radius or 80
	local line_width = line_width or 40
	local new_angle = new_angle or 0

	local mask_img = gfx.image.new(160, 160)
	gfx.pushContext(mask_img)
		gfx.setColor(gfx.kColorBlack)
		gfx.fillRect(0, 0, mask_img:getSize())
		gfx.setColor(gfx.kColorWhite)
		gfx.setLineWidth(line_width)
		gfx.drawArc(160/2, 160/2, radius, new_angle, 360)
	gfx.popContext()
	return mask_img
end

local seconds_ring_img = gfx.image.new('images/seconds_ring_img')
local seconds_ring_sprite = gfx.sprite.new(seconds_ring_img)

local radius = 80

seconds_ring_sprite:moveTo(200, 120)
seconds_ring_sprite:add()

function playdate.update()
	local image <const> = seconds_ring_img:copy()

	image:setMaskImage(get_mask(radius, radius))

	seconds_ring_sprite:setImage(image)

	gfx.sprite.update()

	radius -= 1
	if radius < 0 then
		radius = 80
	end
end

The second alternates between images that are the inverse of each other. If this is the effect you're going for, then you might try alternating the sprite's image draw mode between playdate.graphics.kDrawModeCopy and playdate.graphics.kDrawModeInverted using playdate.graphics.sprite:setImageDrawMode(mode).

inverting

import 'CoreLibs/graphics'

local gfx <const> = playdate.graphics

local function get_mask(radius, line_width, new_angle)
	local radius = radius or 80
	local line_width = line_width or 40
	local new_angle = new_angle or 0

	local mask_img = gfx.image.new(160, 160)
	gfx.pushContext(mask_img)
		gfx.setColor(gfx.kColorBlack)
		gfx.fillRect(0, 0, mask_img:getSize())
		gfx.setColor(gfx.kColorWhite)
		gfx.setLineWidth(line_width)
		gfx.drawArc(160/2, 160/2, radius, new_angle, 360)
	gfx.popContext()
	return mask_img
end

local mask_image <const> = get_mask()
local seconds_ring_img = gfx.image.new(160, 160)
local seconds_ring_sprite = gfx.sprite.new(gfx.image.new(160, 160, gfx.kColorBlack))

local i = 0
local one_second <const> = playdate.display.getRefreshRate()
local two_seconds <const> = 2 * one_second

seconds_ring_sprite:moveTo(200, 120)
seconds_ring_sprite:add()

function playdate.update()
	local which = 1

	if i < one_second then
		-- output 1
		seconds_ring_sprite:setImage(mask_image)
	else
		-- output 2
		seconds_ring_img:setMaskImage(mask_image)
		seconds_ring_sprite:setImage(seconds_ring_img)

		which = 2
	end

	gfx.sprite.update()

	i += 1
	if i >= two_seconds then
		i = 0
	end

	gfx.drawText(which, 200, 0)
end

Your question has made me really curious about how the SDK tracks changes to a sprite's image!

After reading your post for a while, I wonder if you are asking about what causes sprites to get redrawn? By default, a sprite is redrawn when its image changes (see the documentation of playdate.graphics.sprite:setRedrawsOnImageChange(flag)). I'm not sure what counts as changing the image, though. Apparently, changing the mask does not count.

redrawingsprites

Here's what's happening in each frame in the gif above.

  • The top sprite's mask changes. Not redrawn.
  • The second sprite's mask changes and the sprite's image is then passed into the sprite's setImage method. Not redrawn.
  • The third sprite's mask changes and a copy of the sprite's image is then passed into the sprite's setImage method. Redrawn.
  • A square is drawn onto the fourth sprite's image. Redrawn.
  • The fifth sprite's mask changes and the sprite is marked dirty. Redrawn.
import "CoreLibs/graphics"

local SIZE <const> = 36

local gfx <const> = playdate.graphics

local function randomMask()
	local mask <const> = gfx.image.new(SIZE, SIZE, gfx.kColorBlack)

	gfx.pushContext(mask)

	gfx.setColor(gfx.kColorWhite)

	-- Make random pixels visible
	for i = 1, math.random((SIZE * SIZE) / 2, (3 * SIZE * SIZE) / 4) do
		gfx.drawPixel(
			math.random(0, SIZE),
			math.random(0, SIZE)
		)
	end

	gfx.popContext()

	return mask
end

local smiley <const> = gfx.image.new('images/smileyface')

local maskSprite <const> = gfx.sprite.new(smiley:copy())
local maskSetImageSprite <const> = gfx.sprite.new(smiley:copy())
local maskSetImageCopySprite <const> = gfx.sprite.new(smiley:copy())
local pixelChangeSprite <const> = gfx.sprite.new(smiley:copy())
local maskDirtySprite <const> = gfx.sprite.new(smiley:copy())

maskSprite:setCenter(0, 0)
maskSprite:add()

maskSetImageSprite:setCenter(0, 0)
maskSetImageSprite:moveTo(0, SIZE)
maskSetImageSprite:add()

maskSetImageCopySprite:setCenter(0, 0)
maskSetImageCopySprite:moveTo(0, SIZE * 2)
maskSetImageCopySprite:add()

pixelChangeSprite:setCenter(0, 0)
pixelChangeSprite:moveTo(0, SIZE * 3)
pixelChangeSprite:add()

maskDirtySprite:setCenter(0, 0)
maskDirtySprite:moveTo(0, SIZE * 4)
maskDirtySprite:add()

playdate.display.setRefreshRate(1)

function playdate.update()
	local maskSetImageSprite_image <const> = maskSetImageSprite:getImage()
	local maskSetImageCopySprite_image <const> = maskSetImageCopySprite:getImage()

	maskSprite:getImage():setMaskImage(randomMask())

	maskSetImageSprite_image:setMaskImage(randomMask())
	maskSetImageSprite:setImage(maskSetImageSprite_image)

	maskSetImageCopySprite_image:setMaskImage(randomMask())
	maskSetImageCopySprite:setImage(maskSetImageCopySprite_image:copy())

	gfx.pushContext(pixelChangeSprite:getImage())
	gfx.fillRect(math.random(0, SIZE - 10), math.random(0, SIZE - 10), 10, 10)
	gfx.popContext()

	maskDirtySprite:getImage():setMaskImage(randomMask())
	maskDirtySprite:markDirty()

	gfx.sprite.update()
end

Oof. It's a dumb bug that I've had to fix in other places, it's just not setting that dirty flag on the image when you set the mask. I've filed it, and this time I'll go through and check every function that affects the image (e.g. clear() as well). Until then, using sprite:markDirty() is a good workaround.

thank you !!
your first sample is what i need , and accroding to your code , i found out the issues .
that is i NEED to make a image:copy() FIRST , and then setMaskImage() , finally pass this new image use sprite:setImage() to change the sprites . all done .
and about your 5 conclution in your second post , i would like add some comments that i had tried myself.

  • The top sprite's mask changes. Not redrawn.
    (Agreed)
  • The second sprite's mask changes and the sprite's image is then passed into the sprite's setImage method. Not redrawn.
    (Agreed, that is what my problem , even use markDirty(), BUT the most strange thing is , while i use markDirty() , wait for a while , in my situation is : while the mask image in a proper angle (a full white circle in a black background . then the image will get a redraw . i dont know why this happen )
  • The third sprite's mask changes and a copy of the sprite's image is then passed into the sprite's setImage method. Redrawn.
    (Agreed , this is my problem solution now)
  • A square is drawn onto the fourth sprite's image. Redrawn.
    (Agreed )
  • The fifth sprite's mask changes and the sprite is marked dirty. Redrawn.
    (not agreed , refer to the 2rd one above), if you ONLY have your 5th sample's code , you will find out when the random mask is 'full drawing' , the spirte will not change anymore . (may be i still do not make the mask usage clearly , this is still a little bit strange here), since this is a random mask , isnt that should be clear every time ? but now seem setMaskImage will change the image below .

and , all five samples. will make redraw if i used setAlwaysRedraw(true) , that is right , but sure , this is not the good way to solve my problem .

dave , seem markDirty() is still not a solution now , i need to make copy of the original image , and cover the mask , then sprite:setImage() to solove this .

I'm not entirely clear what you're trying to do that's not working right. Can you provide a small code sample?

dave , it is a 60 seconds ring drawing program , codes below , pls run , and wait 60 seconds . you will get the issue .
clock_sprite.lua

import "CoreLibs/graphics"
import "CoreLibs/animator"
import "Corelibs/sprites"
import "CoreLibs/animation"
import "CoreLibs/timer"
import "CoreLibs/frameTimer"


local gfx = playdate.graphics

local ArcBox_Width, ArcBox_Height =  200, 200

class('ArcSprite').extends(gfx.sprite)


function ArcSprite:init(x, y)
    ArcSprite.super.init(self)
    
    self.seconds_ring_img = gfx.image.new("images/japan_wave_pattern_200x200")
    self.seconds_ring_sprite = gfx.sprite.new(self.seconds_ring_img)
    self.seconds_ring_sprite:moveTo(280, 100)
    self.seconds_ring_sprite:add()

end



function ArcSprite:update()
    local current_time = playdate.getTime()
    --printTable(current_time)

    local total_millisecond = current_time['second'] * 1000 + current_time['millisecond']

    local s_angle = self:calculateAngle(total_millisecond, 60 * 1000)
    
    local mask_img = self:get_mask(90, 10, s_angle)
    self.seconds_ring_img:setMaskImage(mask_img)
    self.seconds_ring_sprite:markDirty()

end


function ArcSprite:calculateAngle(x, y)
    local angle = 0
    if y ~= 0 then
        angle = 360 * (x / y)
    end
    return angle
end


function ArcSprite:get_mask(radius, line_width, new_angle)
    local radius = radius or 80
    local line_width = line_width or 40
    local new_angle = new_angle or 0

    local mask_img = gfx.image.new(ArcBox_Width, ArcBox_Height)
    gfx.pushContext(mask_img)
        gfx.setColor(gfx.kColorBlack)
	gfx.fillRect(0, 0, mask_img:getSize())
        gfx.setColor(gfx.kColorWhite)
        gfx.setLineWidth(line_width)
        gfx.drawArc(ArcBox_Width/2, ArcBox_Height/2, radius, new_angle, 360)
    gfx.popContext()
    return mask_img
end

main.lua

import "CoreLibs/graphics"
import "clock_sprite.lua"

local gfx = playdate.graphics

local my_arc = ArcSprite(150, 150)
my_arc:add()


function playdate.update()
	gfx.sprite.update()
end

this is my final output
playdate-20240107-140712

Based on this example, when applying a mask and marking the sprite as dirty, it looks like masks accumulate on sprites whose images have no transparency. Also, when otherSmileySpriteBlackBackground.y was the same as smileySpriteBlackBackground.y, they both got redrawn correctly.


GIF
example


Smiley face images
smileyface
smileyfaceblackbackground
smileyfacewhitebackground


Code

import "CoreLibs/graphics"

local gfx <const> = playdate.graphics

local function randomImage(w, h)
	local image <const> = gfx.image.new(w, h, gfx.kColorBlack)

	gfx.pushContext(image)

	gfx.setColor(gfx.kColorWhite)

	-- Make random pixels visible
	for i = 1, math.random((w * w) / 2, (3 * h * h) / 4) do
		gfx.drawPixel(
			math.random(0, w - 1),
			math.random(0, h - 1)
		)
	end

	gfx.popContext()

	return image
end

local function randomSpriteMask(sprite)
	sprite:getImage():setMaskImage(randomImage(sprite.width, sprite.height))
	sprite:markDirty()
end

local smileySprite <const> = gfx.sprite.new(gfx.image.new('images/smileyface'))
local circleSprite <const> = gfx.sprite.new(gfx.image.new(smileySprite.width, smileySprite.height))
local blackSprite <const> = gfx.sprite.new(gfx.image.new(smileySprite.width, smileySprite.height, gfx.kColorBlack))
local smileySpriteBlackBackground <const> = gfx.sprite.new(gfx.image.new('images/smileyfaceblackbackground'))
local otherSmileySpriteBlackBackground <const> = gfx.sprite.new(gfx.image.new('images/smileyfaceblackbackground'))
local smileySpriteWhiteBackground <const> = gfx.sprite.new(gfx.image.new('images/smileyfacewhitebackground'))
local image <const> = gfx.image.new('images/smileyfaceblackbackground')

smileySprite:setCenter(0, 0)
smileySprite:moveTo(10, 10)
smileySprite:add()

circleSprite:setCenter(0, 0)
circleSprite:moveTo(smileySprite.x, smileySprite.y + smileySprite.height + 10)
circleSprite:add()

blackSprite:setCenter(0, 0)
blackSprite:moveTo(smileySprite.x, circleSprite.y + circleSprite.height + 10)
blackSprite:add()

smileySpriteBlackBackground:setCenter(0, 0)
smileySpriteBlackBackground:moveTo(smileySprite.x, blackSprite.y + blackSprite.height + 10)
smileySpriteBlackBackground:add()

smileySpriteWhiteBackground:setCenter(0, 0)
smileySpriteWhiteBackground:moveTo(smileySprite.x, smileySpriteBlackBackground.y + smileySpriteBlackBackground.height + 10)
smileySpriteWhiteBackground:add()

otherSmileySpriteBlackBackground:setCenter(0, 0)
otherSmileySpriteBlackBackground:moveTo(smileySpriteBlackBackground.x + (2 * (smileySpriteBlackBackground.width + 10)), smileySpriteBlackBackground.y + 1)
otherSmileySpriteBlackBackground:add()

gfx.pushContext(circleSprite:getImage())
gfx.clear(gfx.kColorClear)
gfx.setColor(gfx.kColorWhite)
gfx.fillCircleInRect(0, 0, circleSprite.width, circleSprite.height)
gfx.setColor(gfx.kColorBlack)
gfx.fillCircleInRect(10, 10, circleSprite.width - 20, circleSprite.height - 20)
gfx.popContext()

playdate.display.setRefreshRate(5)

function playdate.update()
	randomSpriteMask(smileySprite)
	randomSpriteMask(circleSprite)
	randomSpriteMask(blackSprite)
	randomSpriteMask(smileySpriteBlackBackground)
	randomSpriteMask(smileySpriteWhiteBackground)

	otherSmileySpriteBlackBackground:getImage():setMaskImage(randomImage(otherSmileySpriteBlackBackground.width, otherSmileySpriteBlackBackground.height))
	otherSmileySpriteBlackBackground:markDirty()

	gfx.sprite.update()

	image:setMaskImage(randomImage(image:getSize()))
	image:draw(smileySpriteBlackBackground.x + smileySpriteBlackBackground.width + 10, smileySpriteBlackBackground.y)
end