Last active
April 10, 2025 19:13
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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