Using image masks to simulate a hologram fan

I was looking at hologram fans, and I realized how conceptually simple it is to make a stick of lights display an image. The fan is just a rapidly-spinning, frequently-updated image mask! Playdate can do that.

Playdate's image and drawing functions are some of my favorite parts of the SDK, even with just a 1-bit display. Constraints foster creativity, after all. Maybe this will help someone learn how to use image masks?

At first, I drew straight lines on the mask image, but partial ellipses worked out better. The spinning fan mask can create gray and wagon-wheel effects. Here's example output, code, and my incredibly artistic stick figure animation.

example

import 'CoreLibs/animation'

-- Angle range of partial ellipses
local ELLIPSE_RANGE <const> = 30

-- How much to change the fan's per-frame angle change when pressing up or down
local FAN_DELTA_INPUT_STEP <const> = 1 / 10

-- Animation constants
local ANIMATION_PATH <const> = 'animation'
local ANIMATION_SPEED <const> = 100

-- Where to draw the animation
local WHERE_X <const> = 200
local WHERE_Y <const> = 120

-- Less typing and reading
local graphics <const> = playdate.graphics

-- Variables to control fan spin
local fanDelta = 20
local fanDeltaInput = 0

-- Variables to control fan blade configuration
local ellipseDelta = 90
local ellipseParts = math.floor(360 / ellipseDelta)

-- Animation
local animation <const> = graphics.animation.loop.new(
	ANIMATION_SPEED,
	graphics.imagetable.new(ANIMATION_PATH),
	true
)
local width <const>, height <const> = animation:image():getSize()

-- Fan mask
local mask <const> = graphics.image.new(width, height)
local ellipseSize <const> = math.sqrt((width ^ 2) + (height ^ 2))
local ellipseX <const> = (width - ellipseSize) / 2
local ellipseY <const> = (height - ellipseSize) / 2

-- Variable to track fan spin
local angle = 0

-- Go fast
playdate.display.setRefreshRate(50)

-- White for drawing to mask
graphics.setColor(graphics.kColorWhite)

function playdate.update()
	-- Get current animation image
	local image <const> = animation:image()

	-- Variable for drawing fan blades
	local ellipseAngle = angle

	-- Update fan rotation speed
	fanDelta += fanDeltaInput * FAN_DELTA_INPUT_STEP

	-- Create frame's fan mask
	mask:clear(graphics.kColorBlack)

	graphics.pushContext(mask)
	for i = 1, ellipseParts do
		graphics.fillEllipseInRect(
			ellipseX,
			ellipseY,
			ellipseSize,
			ellipseSize,
			ellipseAngle,
			ellipseAngle + ELLIPSE_RANGE
		)

		ellipseAngle = (ellipseAngle + ellipseDelta) % 360
	end
	graphics.popContext()

	-- Apply fan mask to animation
	image:setMaskImage(mask)

	-- Clear animation area
	graphics.fillRect(WHERE_X, WHERE_Y, width, height)

	-- Draw animation
	image:draw(WHERE_X, WHERE_Y)

	-- Clear variable display area
	graphics.fillRect(0, 0, 400, 80)

	-- Display interesting variable values
	graphics.drawText(
		string.format(
			'Fan delta theta: %f'..
			'\nEllipse delta theta: %f'..
			'\nEllipses: %d', fanDelta, ellipseDelta, ellipseParts
		),
		0,
		0
	)

	-- Display actual frame rate
	playdate.drawFPS(0, 80)

	-- Spin fan
	angle = (angle + fanDelta) % 360
end

function playdate.cranked(change, acceleratedChange)
	-- Update angular distance between fan blades
	ellipseDelta = (ellipseDelta + change) % 360

	-- Update number of fan blades
	ellipseParts = math.floor(360 / ellipseDelta)
end

function playdate.downButtonDown()
	-- Decrease fan rotation speed
	fanDeltaInput = -1
end

function playdate.downButtonUp()
	-- Maintain fan rotation speed
	fanDeltaInput = 0
end

function playdate.upButtonDown()
	-- Increase fan rotation speed
	fanDeltaInput = 1
end

function playdate.upButtonUp()
	-- Maintain fan rotation speed
	fanDeltaInput = 0
end

function playdate.leftButtonDown()
	-- Decrease refresh rate
	playdate.display.setRefreshRate(playdate.display.getRefreshRate() - 1)
end

function playdate.rightButtonDown()
	-- Increase refresh rate
	playdate.display.setRefreshRate(playdate.display.getRefreshRate() + 1)
end

animation-table-35-69