Circular Progress Bar - EditableImage version

Disclaimer: This method requires you to have access to EditableImages, which requires ID verification.


Module to create circular progress bars. Uses an EditableImage.
It is not a Roblox package or sorts because it is just a few lines of code. Just copy it away.

Note that this forces rounded caps.

Source code
--!strict
local vec2 = Vector2.new
local cos, sin, floor, ceil, sqrt, atan2, pi, add = math.cos, math.sin, math.floor, math.ceil, math.sqrt, math.atan2, math.pi, table.insert

local function is_between(a: number, b: number, c: number)
	if b <= c then
		return a >= b and a <= c
	else
		return a >= b or a <= c
	end
end

type props = {
	-- main bar
	source: EditableImage;
	center: Vector2?;
	radius: number;
	thickness: number;
	a_start: number;
	a_end: number;
	color: Color3?;
	alpha: number?;

	-- border
	border_thickness: number?;
	border_color: Color3?;
	border_alpha: number?;
}

type propx = {
	source: EditableImage;
	center: Vector2;  
	radius: number;
	thickness: number;
	a_start: number; 
	a_end: number;
	color: Color3;
	alpha: number;
}

local function draw(props: propx)
	local source = props.source
	local center = props.center or props.source.Size/2
	local thickness = props.thickness or 1
	local color = props.color or Color3.new(1,1,1)

	-- endcaps
	local cap_r = thickness / 2 -- radius
	local cap_pos_1 = center + vec2(cos(props.a_start), sin(props.a_start)) * props.radius -- positions
	local cap_pos_2 = center + vec2(cos(props.a_end), sin(props.a_end)) * props.radius

	-- inner and outer radius of the bar
	local ir = props.radius - cap_r
	local xr = props.radius + cap_r

	-- limits for scanline
	local min_y = floor(center.Y - xr)
	local max_y = ceil(center.Y + xr)

	for y = min_y, max_y do
		local spans = {}
		local in_span = false
		local span_str
		local min_x = floor(center.X - xr)
		local max_x = ceil(center.X + xr)

		for x = min_x, max_x do
			local dx, dy = x - center.X, y - center.Y
			local dist = sqrt(dx*dx + dy*dy)
			local ok = 
				dist >= ir
				and dist <= xr
				and is_between(
					(atan2(dy, dx) + 2 * pi) % (2 * pi), props.a_start, props.a_end
				)
			if ok then
				if not in_span then
					in_span = true
					span_str = x
				end
			elseif in_span then
				in_span = false
				add(spans, {span_str, x-1})
			end
		end
		if in_span then
			add(spans, {span_str, max_x})
		end

		for _, span: {number} in spans do
			local x0, x1 = span[1], span[2]
			local width = x1 - x0 + 1
			local buf = buffer.create(width * 4)
			local idx = 0
			for i = 1, width do
				buffer.writeu8(buf, idx, floor(color.R * 255))
				buffer.writeu8(buf, idx+1, floor(color.G * 255))
				buffer.writeu8(buf, idx+2, floor(color.B * 255))
				buffer.writeu8(buf, idx+3, floor(((1 - props.alpha) * 255))) 
                idx += 4
			end

			source:WritePixelsBuffer(vec2(x0, y), vec2(width, 1), buf)
		end
	end

	-- draw caps
	source:DrawCircle(cap_pos_1, cap_r, color, 0, Enum.ImageCombineType.Overwrite)
	source:DrawCircle(cap_pos_2, cap_r, color, 0, Enum.ImageCombineType.Overwrite)
end

return function(props: props)
	if typeof(props.source) ~= 'Object' then
		error('did not provide a valid EditableImage')
	end

	if (type(props.a_start) ~= 'number') and (type(props.a_end) ~= 'number') then
		error('invalid angles provided')
	end

	if type(props.radius) ~= 'number' or props.radius <= 0 then
		error('invalid radius provided')
	end

	if type(props.thickness) ~= 'number' or props.thickness <= 0 then
		error('invalid thickness provided')
	end

	-- normalize angles to the top instead of right
	local a_start = (math.rad(props.a_start) - (pi / 2) + 2 * pi) % (2 * pi)
	local a_end = (math.rad(props.a_end) - (pi / 2) + 2 * pi) % (2 * pi)

	local source = props.source
	local center = props.center or source.Size / 2
	local thickness = props.thickness or 1
	local radius = props.radius
	local color = props.color or Color3.new(1,1,1)
	local alpha = props.alpha or 0;
	local border_thickness = props.border_thickness or 0
	local border_color = props.border_color or Color3.new()
	local border_alpha = props.border_alpha or 0
	
	if border_thickness > 0 then
		draw {
			source = source;
			center = center;
			radius = radius;
			thickness = border_thickness * 2 + thickness;
			a_start = a_start;
			a_end = a_end;
			color = border_color;
			alpha = border_alpha
		}
	end

	draw {
		source = source;
		center = center;
		radius = radius;
		thickness = thickness;
		a_start = a_start;
		a_end = a_end;
		color = color;
		alpha = alpha
	}
end
How to use

It is but a simple function that takes a table with the following keys:

Property Type Required Additional Notes
source EditableImage true
center Vector2 false Defaults to center if not provided
radius number true
thickness number true
a_start number true Takes degrees (0-360). 0 is image top
a_end number true Takes degrees (0-360)
color Color3 false Defaults to white
alpha number false 0-1, opaque-transparent
border_thickness number false If not provided won’t render
border_color Color3 false Defaults to black
border_alpha number false 0-1

And some examples.

Example 1: Simple Arc

Code:

local draw = require(...)
local img = whatever_way_to_get_an_Editable_Image_Size_500
draw {
	source = img;
	radius = 120;
	thickness = 60;
	a_start = 270;
	a_end = 120;
	color = Color3.new(0.439216, 1, 0.552941);
	alpha = 0;
	border_thickness = 10;
	border_color = Color3.new()
}

Result:

Example 2: Sectioned 'Analytics' Bar

Code:

local draw = require(...)
local img = EditableImageWithSize512
draw {
	source = img,
	radius = 256 - 48,
	thickness = 48,
	a_start = 20,
	a_end = 250,
	color = Color3.new(0.27451, 0.854902, 0.439216),
	alpha = 0,
	border_thickness = 0,
}

draw {
	source = img,
	radius = 256 - 48,
	thickness = 48,
	a_start = 270,
	a_end = 340,
	color = Color3.new(0.854902, 0.262745, 0.270588),
	alpha = 0,
	border_thickness = 0,
}

draw {
	source = img,
	radius = 256 - 48,
	thickness = 48,
	a_start = 0,
	a_end = 0,
	color = Color3.new(0.839216, 0.839216, 0.839216),
	alpha = 0,
	border_thickness = 0,
}

Wanted to create a circular loading bar but my brain isn’t working at the moment. Anyway have fun.

How does it perform?

  • Simple. Smaller size = faster rendering. For a 512px image, it renders in around 50ms. Which is slow. Quite slow actually. Example 2 takes around ~130ms. Can it be faster? Probably. I’ll figure it out.
14 Likes
for i = 1, width do
    buffer.writeu8(buf, idx, floor(color.R * 255))
    buffer.writeu8(buf, idx+1, floor(color.G * 255))
    buffer.writeu8(buf, idx+2, floor(color.B * 255))
    buffer.writeu8(buf, idx+3, floor(((1 - props.alpha) * 255))) 
    idx += 4
end

You can probably use bitpacking to make this faster.
Given r, g, b, and a, you can create a single number that represents a colour:

bit32.bor(
     bit32.lshift(a, 24),
     bit32.lshift(b, 16),
     bit32.lshift(g, 8),
     r
)

Then write this via buffer.writeu32
In your case, since the A value is changing, start with a value of 0:

local color = bit32.bor(
     bit32.lshift(0, 24),
     bit32.lshift(b, 16),
     bit32.lshift(g, 8),
     r
)

And set the A manually when needed:

-- ... Previous code

local newAlpha = 127 
local newCol = bit32.bor(bit32.band(color, 0x00FFFFFF), bit32.lshift(newAlpha , 24))

I’m almost certain this is faster than 4 writes… You can see an example of this being handled here!

6 Likes

This would be handy for many systems. I am going to likely mess with this a little bit to replicated the reload/shooting gui elements from WWZ. Thank you.

1 Like

I bearly understand how bit works on this platform let alone in Lua in general. Is there anything that helped you learn and also could you explain how this is faster?

Curious.

The first thing you’d want to know is how binary works in general; I recommend a video as such (though there are many more types of videos and resources available online)

You should also look into how computers store numbers (specifically u8 and u32 for this)

Some simple terminology:

  • A bit, 1 binary digit (0 or 1)
  • A byte, (8 bits, e.g 10101101)

The trick I showed makes use of a u8 (u means unsigned, meaning positive 8 bit integer, meaning 0 - 255), and a u32 (essentially, 4 u8s. 8*4 = 32)

Instead of writing the RGBA as each number value separately EVERY PIXEL, we use 32 bits and write once. This is explained in the “Color” section of the OSGL documentation.

Here’s an example. Let’s say you wanted to write the colour (RGBA): 255, 127, 0, 255
Think about each number as it’s stored internally, in binary:
255 is 11111111 (remember, a u8 is 8 BITS)
127 is 11111110
0 is 00000000
255 is 11111111

Simply, the program merges all of these into 1 big u32:

Channel Binary Denary
R 11111111 255
G 11111110 127
B 00000000 0
A 11111111 255

The full colour would be stored as a single number:

11111111 11111110 00000000 11111111

This significantly speeds up the program. No need for 4 writes to the buffer, but only 1.
Hope I helped in some way!

2 Likes

Self taught computer nerd and son of a dad who has degree in computer sci so I know computers pretty well I feel. But thank you.


Makes sense thanks.

Heh. Same here. Hope I helped!

1 Like

Hey, forgot to reply. Applied this and did see some performance improvements (a few mils), so thanks for the suggestion.

Figured what’s making the thing slow - I’ll see if I can optimise it.

OSGL looks super nice too, don’t know why I haven’t heard of it before.