How can I increase performance on this UIGradient based pixel canvas module?

For context, this module allows me to draw pixels to the screen in a gui. I have spent about a year trying to get this module to run as fast as possible without sacrificing functionality.

I have now gotten this thing to the point, where I don’t think I can boost performance on my own, which is why I am here. I have ran out of ideas on how to reduce lag for rendering lots and lots of colours.

Any ideas on how I can further improve performance and make this thing as fast as possible?

--[[

FastCanvas is a modified version of Greedy/GradientCanvas by BoatBomber. 
This version has the ability to remove blur artefacts from the original module and improve performance. 
There are also some other minor additions to fit CanvasDraw's needs.

Original module: https://github.com/boatbomber/GradientCanvas

]]

local module = {}

local GuiPool = require(script.GuiPool)
--local Util = require(script.Util)

-- Micro optimisations
local TableInsert = table.insert
local TableClear = table.clear
local TableCreate = table.create

local Mclamp = math.clamp

local UDim2FromScale = UDim2.fromScale
local ColorSeqKeyPNew = ColorSequenceKeypoint.new
local ColorSeqNew = ColorSequence.new

function module.new(ResX: number, ResY: number, BlurEnabled: boolean)
	local Canvas = {
		_Active = 0,
		_ColumnFrames = {},
		_UpdatedColumns = {},

		--Threshold = 2,
		--LossyThreshold = 4,
	}

	local invX, invY = 1 / ResX, 1 / ResY
	--local dist = ResY * 0.03

	-- Generate initial grid of color data
	local Grid = TableCreate(ResX)
	for x = 1, ResX do
		Grid[x] = TableCreate(ResY, Color3.new(0.98, 1, 1))
	end
	Canvas._Grid = Grid

	-- Create a pool of Frame instances with Gradients
	do
		local Pixel = Instance.new("Frame")
		Pixel.BackgroundColor3 = Color3.new(1, 1, 1)
		Pixel.BorderSizePixel = 0
		Pixel.Name = "Pixel"
		local Gradient = Instance.new("UIGradient")
		Gradient.Name = "Gradient"
		Gradient.Rotation = 90
		Gradient.Parent = Pixel

		Canvas._Pool = GuiPool.new(Pixel, ResX)
		Pixel:Destroy()
	end

	-- Create GUIs
	local Gui = Instance.new("Frame")
	Gui.Name = "GradientCanvas"
	Gui.BackgroundTransparency = 1
	Gui.ClipsDescendants = true
	Gui.Size = UDim2.fromScale(1, 1)
	Gui.Position = UDim2.fromScale(0.5, 0.5)
	Gui.AnchorPoint = Vector2.new(0.5, 0.5)

	local AspectRatio = Instance.new("UIAspectRatioConstraint")
	AspectRatio.AspectRatio = ResX / ResY
	AspectRatio.Parent = Gui

	local Container = Instance.new("Folder")
	Container.Name = "FrameContainer"
	Container.Parent = Gui

	-- Define API
	local function createGradient(colorData, x, pixelStart, pixelCount)
		local Sequence = TableCreate(#colorData)
		for i, data in colorData do
			Sequence[i] = ColorSeqKeyPNew(Mclamp(data.p / pixelCount, 0, 1), data.c)
		end

		local Frame = Canvas._Pool:Get()
		Frame.Position = UDim2FromScale(invX * (x - 1), pixelStart * invY)
		Frame.Size = UDim2FromScale(invX, invY * pixelCount)
		Frame.Gradient.Color = ColorSeqNew(Sequence)
		Frame.Parent = Container

		if Canvas._ColumnFrames[x] == nil then
			Canvas._ColumnFrames[x] = { Frame }
		else
			TableInsert(Canvas._ColumnFrames[x], Frame)
		end

		Canvas._Active += 1
	end

	function Canvas:Destroy()
		TableClear(Canvas._Grid)
		TableClear(Canvas)
		Gui:Destroy()
	end

	function Canvas:SetParent(parent: Instance)
		Gui.Parent = parent
	end

	function Canvas:SetPixel(x: number, y: number, color: Color3)
		local Col = self._Grid[x]

		if Col[y] ~= color then
			Col[y] = color
			self._UpdatedColumns[x] = Col
		end
	end
	
	function Canvas:GetPixel(x: number, y: number)
		--local Col = self._Grid[x]
		--if not Col then
		--	return
		--end

		return self._Grid[x][y]
	end

	function Canvas:Clear(x: number?)
		if x then
			local column = self._ColumnFrames[x]
			if column == nil then return end

			for _, object in column do
				self._Pool:Return(object)
				self._Active -= 1
			end
			TableClear(column)
		else
			for _, object in Container:GetChildren() do
				self._Pool:Return(object)
			end
			self._Active = 0
			TableClear(self._ColumnFrames)
		end
	end

	function Canvas:Render()
		for x, column in self._UpdatedColumns do
			self:Clear(x)

			local colorCount, colorData = 1, {
				{ p = 0, c = column[1] },
			}

			local pixelStart, pixelCount = 0, 0
			--local pixelStart, lastPixel, pixelCount = 0, 0, 0
			
			local lastColor = column[1]

			-- Compress into gradients
			for y, color in column do
				pixelCount += 1

				-- Early exit to avoid the delta check on direct equality
				if lastColor == color then
					continue
				end

				--local delta = Util.DeltaRGB(lastColor, color)
				--if delta > self.Threshold then
				local offset = y - pixelStart - 1

				--if (delta > self.LossyThreshold) or (y-lastPixel > dist) then
				
				TableInsert(colorData, { p = offset - 0.08, c = lastColor })
				--colorCount += 1
				
				TableInsert(colorData, { p = offset, c = color })
				colorCount += 2

				lastColor = color
				--lastPixel = y

				if colorCount > 18 then
					TableInsert(colorData, { p = pixelCount, c = color })
					createGradient(colorData, x, pixelStart, pixelCount)

					pixelStart = y - 1
					pixelCount = 0
					colorCount = 1
					TableClear(colorData)
					colorData[1] = { p = 0, c = color }
				end
				--end
			end

			if pixelCount + pixelStart ~= ResY then
				pixelCount += 1
			end
			TableInsert(colorData, { p = pixelCount, c = lastColor })
			createGradient(colorData, x, pixelStart, pixelCount)
		end

		TableClear(self._UpdatedColumns)
	end

	return Canvas
end

return module
GuiPool module
local module = {}

local OFF_SCREEN = UDim2.fromOffset(0, math.huge)

local TableInsert = table.insert
local TableRemove = table.remove

function module.new(original: GuiObject, initSize: number?)
	initSize = initSize or 50

	local Pool = {
		_Available = table.create(initSize),
		_Source = original:Clone(),
		_Index = initSize,
	}

	for i = 1, initSize do
		Pool._Available[i] = Pool._Source:Clone()
	end

	function Pool:Get()
		local Index = self._Index
		
		if Index > 0 then
			local object = self._Available[Index]
			TableRemove(self._Available, Index)
			self._Index -= 1
			return object
		end

		return self._Source:Clone()
	end

	function Pool:Return(object: GuiObject)
		object.Position = OFF_SCREEN
		TableInsert(self._Available, object)
		self._Index += 1
	end

	return Pool
end

return module
1 Like

Your method for creating classes will produce duplicate functions because they contain non-constant upvalues, causing them to not be cached. If you want to type-check lua classes, I made a thread about my method on achieving that with idiomatic metatables.

If you want to hide private properties and methods from the interface, you can make a “pseudo-type” and ascribe it to the returned class. For example:

export type FastCanvas = {
	-- properties that you want exposed
	_Active: number,
	_ColumnFrames: {TYPE},
	_UpdatedColumns: {TYPE},
	
	-- add 'self' for method calls
	Destroy: (self: FastCanvas) -> (),
	SetParent: (self: FastCavas, parent: Instance) -> (),
	...
}

-- ascribe 'any' then ascribe 'FastCanvas' to override
return (module :: any) :: {new: FastCanvas}

Otherwise, the code’s logic is as optimized as I can imagine it. You may want to experiment around using hashmaps instead of arrays for the pool, since those can have faster lookups in some cases.

The localizing of library functions is a little much, but I understand if you’re trying to squeeze as much performance out there is no harm for it.