Skip to content

Instantly share code, notes, and snippets.

@MrChickenRocket
Last active April 10, 2025 19:13
Show Gist options
  • Save MrChickenRocket/b3194eb7ffa35834b5bce0d9e5a3d4ce to your computer and use it in GitHub Desktop.
Save MrChickenRocket/b3194eb7ffa35834b5bce0d9e5a3d4ce to your computer and use it in GitHub Desktop.
Add SDF Strokes to editableImages in Roblox. Allows either full compositing or generation of a separate stroke image.
local module = require(script.StrokeScript)
local gui = game.Players.LocalPlayer.PlayerGui:WaitForChild("ScreenGui"):WaitForChild("ImageLabel")
local comp = game.Players.LocalPlayer.PlayerGui:WaitForChild("ScreenGui"):WaitForChild("CompositeImageLabel")
--Build a separate stroke image (white) and display it behind the src image
local stroke = 16
local strokeImage, origSize, finalSize = module:MakeStrokeImageFromAssetIdAsync(gui.Image, stroke)
if (strokeImage) then
local strokeImg = gui:Clone()
strokeImg.Parent = gui.Parent
strokeImg.ImageContent = Content.fromObject(strokeImage)
strokeImg.ImageColor3 = Color3.new(1,1,1)
local ratio = (finalSize / origSize)
--Offset version
strokeImg.Size = UDim2.new(0,strokeImg.Size.X.Offset * ratio.X,0, strokeImg.Size.Y.Offset * ratio.Y)
--Scale version
--strokeImg.Size = UDim2.new(strokeImg.Size.X.Scale * ratio.X,strokeImg.Size.Y.Scale * ratio.Y, 0)
gui.Parent = strokeImg
gui.AnchorPoint = Vector2.new(0.5,0.5)
gui.Position = UDim2.new(0.5,0,0.5,0)
end
--Composite an image and stroke together
local compositeImage = module:AddStrokeToImageFromAssetIdAsync(comp.Image, stroke, Color3.new(0,0,0), 1)
if (compositeImage) then
comp.ImageContent = Content.fromObject(compositeImage)
end
local module = {}
--MCR, Jan 2025
--Will return an editImage, the old size, and the new size if required
--eg: a 100x100 image with a 4 stroke will come back as 110x110 -> stroke * 2 + 2
--SLOW! Not doing any of the fun speedups for generating the SDF
--Also doesnt check for upper image size
function module:MakeStrokeImageFromAssetIdAsync(assetId : number, strokeSize : number) : EditableImage?
if (strokeSize < 1) then
strokeSize = 1
end
if (strokeSize > 16) then
strokeSize = 16 --Be reasonable sir!
end
local success, editableImage = pcall(function()
return game.AssetService:CreateEditableImageAsync(Content.fromUri(assetId))
end)
if (success == false) then
return nil
end
local needsPadding = module:CheckIfImageNeedsPadding(editableImage, strokeSize+1)
if (needsPadding == false) then
local returnImage = module:CreateStrokeFromEditableImage(editableImage, strokeSize, Color3.new(1,1,1), 1, false)
return returnImage, editableImage.Size, returnImage.Size
else
local paddedImage = module:PadEditableImage(editableImage, strokeSize + 1)
local returnImage = module:CreateStrokeFromEditableImage(paddedImage, strokeSize, Color3.new(1,1,1), 1, false)
return returnImage, editableImage.Size, returnImage.Size
end
end
function module:AddStrokeToImageFromAssetIdAsync(assetId : number, strokeSize : number, color:Color3, alpha:number) : EditableImage?
if (strokeSize < 1) then
strokeSize = 1
end
if (strokeSize > 16) then
strokeSize = 16 --Be reasonable sir!
end
local success, editableImage = pcall(function()
return game.AssetService:CreateEditableImageAsync(Content.fromUri(assetId))
end)
if (success == false) then
return nil
end
local needsPadding = module:CheckIfImageNeedsPadding(editableImage, strokeSize+1)
if (needsPadding == false) then
local returnImage = module:CreateStrokeFromEditableImage(editableImage, strokeSize, color, alpha, true)
return returnImage, editableImage.Size, returnImage.Size
else
local paddedImage = module:PadEditableImage(editableImage, strokeSize + 1)
local returnImage = module:CreateStrokeFromEditableImage(paddedImage, strokeSize, color, alpha, true)
return returnImage, editableImage.Size, returnImage.Size
end
end
function module:CheckIfImageNeedsPadding(editableImage:EditableImage, numPixels : number) : boolean
if (numPixels < 0) then
numPixels = 0
end
local needsPadding = false
local srcPixels = editableImage:ReadPixelsBuffer(Vector2.zero, editableImage.Size)
--Could just do a u32 read here, but whatever
local ix = 0
local width = editableImage.Size.X
local height = editableImage.Size.Y
local alphaBuffer = {}
for x = 0,editableImage.Size.X - 1 do
for y = 0, editableImage.Size.Y - 1 do
local r = buffer.readu8(srcPixels,ix)
ix+=1
local g = buffer.readu8(srcPixels,ix)
ix+=1
local b = buffer.readu8(srcPixels,ix)
ix+=1
local a = buffer.readu8(srcPixels,ix)
ix+=1
alphaBuffer[x+y*width] = a
end
end
--Top rectangle
for x=0,width-1 do
for y =0,numPixels do
if alphaBuffer[x+y*width] > 0 then
return true
end
end
end
--Bottom Rectangle
for x = 0, width-1 do
for y = height - 1, height - numPixels - 1 do
if alphaBuffer[x+y*width] > 0 then
return true
end
end
end
--Left rectangle
for x = 0, numPixels do
for y = 0, height-1 do
if alphaBuffer[x+y*width] > 0 then
return true
end
end
end
--Right rectangle
for x = width - 1, width - numPixels - 1 do
for y = 0, height-1 do
if alphaBuffer[x+y*width] > 0 then
return true
end
end
end
return false
end
function module:PadEditableImage(editableImage: EditableImage, numPixels : number)
if (numPixels < 0) then
numPixels = 0
end
local options = { Size = Vector2.new(editableImage.Size.X + numPixels*2,editableImage.Size.Y + numPixels*2) }
local targetImage = game.AssetService:CreateEditableImage(options)
targetImage:DrawImage(Vector2.new(numPixels,numPixels), editableImage, Enum.ImageCombineType.AlphaBlend)
return targetImage
end
local function smoothStep(edge0 : number, edge1 : number, x : number)
local t = math.clamp((x - edge0) / (edge1 - edge0), 0, 1)
return t * t * (3 - 2 * t)
end
function module:CreateStrokeFromEditableImage(editableImage : EditableImage, strokeSize : number, strokeColor:Color3, strokeAlpha:number, useInteriorImage : boolean)
--create a clone the same size
local options = { Size = editableImage.Size }
local targetImage = game.AssetService:CreateEditableImage(options)
local srcPixels = editableImage:ReadPixelsBuffer(Vector2.zero, editableImage.Size)
local dstPixels = buffer.create(editableImage.Size.X * editableImage.Size.Y * 4)
local emptyValue = 99999
local width = editableImage.Size.X
local height = editableImage.Size.Y
local alphaBuffer = {}
local colorBuffer = {}
local distBuffer = {}
--Could just do a u32 read here, but whatever
local ix = 0
for x = 0,editableImage.Size.X - 1 do
for y = 0, editableImage.Size.Y - 1 do
local r = buffer.readu8(srcPixels,ix)
ix+=1
local g = buffer.readu8(srcPixels,ix)
ix+=1
local b = buffer.readu8(srcPixels,ix)
ix+=1
local a = buffer.readu8(srcPixels,ix)
ix+=1
colorBuffer[x+y*width] = Color3.new(r / 255, g / 255, b / 255)
alphaBuffer[x+y*width] = a / 255
end
end
--Do square search
local width0 = width-1
local height0 = height-1
local boxSize = strokeSize + 1
for x=0,width0 do
for y=0,height0 do
if alphaBuffer[x+y*width] < 1 then
--we only care about transparent pixels
local point = Vector3.new(x,y,0)
local x0 = math.max(x-boxSize, 0)
local x1 = math.min(x+boxSize, width0)
local y0 = math.max(y-boxSize, 0)
local y1 = math.min(y+boxSize, height0)
local best = emptyValue
for dx = x0, x1 do
for dy = y0, y1 do
local pixel = alphaBuffer[dx+dy*width]
if (pixel > 0) then
local mag = (Vector3.new(dx,dy) - point).Magnitude
--Fudge - add the alpha as extra distance for antialiasing
mag-=pixel*0.5
if (mag < best) then
best = mag
end
end
end
end
if (best ~= emptyValue) then
distBuffer[x+y*width] = best
end
end
end
end
local strokeR = strokeColor.R * 255
local strokeG = strokeColor.G * 255
local strokeB = strokeColor.B * 255
local strokeOffset = strokeSize - 1
local wx = 0
for x = 0,width0 do
for y = 0,height0 do
local src = alphaBuffer[x+y*width]
--Interior pixels
if (src > 0) then
if (useInteriorImage == false) then
buffer.writeu8(dstPixels,wx,strokeR)
wx+=1
buffer.writeu8(dstPixels,wx,strokeG)
wx+=1
buffer.writeu8(dstPixels,wx,strokeB)
wx+=1
buffer.writeu8(dstPixels,wx,strokeAlpha * 255)
wx+=1
continue
else
--Read the interior pixel and write
local srcColor = colorBuffer[x+y*width]
local srcAlpha = alphaBuffer[x+y*width]
local invSrcAlpha = 1 - srcAlpha
--Is this right? I assume so, but if UI images have PMA applied this would just be an add then..
local fr = (srcColor.R * srcAlpha + (strokeColor.R * invSrcAlpha)) * 255
local fg = (srcColor.G * srcAlpha + (strokeColor.G * invSrcAlpha)) * 255
local fb = (srcColor.B * srcAlpha + (strokeColor.B * invSrcAlpha)) * 255
buffer.writeu8(dstPixels,wx,fr)
wx+=1
buffer.writeu8(dstPixels,wx,fg)
wx+=1
buffer.writeu8(dstPixels,wx,fb)
wx+=1
buffer.writeu8(dstPixels,wx, 255)
wx+=1
continue
end
end
local ab = distBuffer[x+y*width]
if (ab == nil) then
--exterior blank pixels
buffer.writeu32(dstPixels,wx,0)
wx+=4
else
--Pixels with a stroke on it (RGB is white)
buffer.writeu8(dstPixels,wx,strokeR)
wx+=1
buffer.writeu8(dstPixels,wx,strokeG)
wx+=1
buffer.writeu8(dstPixels,wx,strokeB)
wx+=1
local alpha = 1 - smoothStep(strokeSize-0.5,strokeSize+0.5,ab)
buffer.writeu8(dstPixels,wx,strokeAlpha * alpha * 255)
wx+=1
end
end
end
targetImage:WritePixelsBuffer(Vector2.zero, targetImage.Size, dstPixels)
return targetImage
end
return module
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment