A complete guide to EditableImages

Introduction


Heya! Today, I’m here to teach you how to use EditableImages - The good and bad practices; and how to implement multithreading with them. You’ll learn about existing libraries, how to write to a pixel-buffer, existing formats for writing images, videos, fonts, and more.

:warning: Note
Due to Discourse’s limit of 50,000 characters, parts of this tutorial have been abstracted and converted to images. I apologise if this disrupts your reading experience.

For this reason, it’s best if you read this on the “Roblox Dark” theme. If you wish, you can download the original hardcopy here. You can paste it into a post editor, and view the full, true source there. I apologise once again for this inconvenience.

Table of Contents


EditableImages


This sections includes:

  • What an EditableImage actually is
  • Existing libraries (and where they fall short!)
  • How to create your own

(Think of this as a “why, why not, and how” guide.)

What are they?

An extract from the Roblox API tells us that they:

… allow for the runtime creation and manipulation of Images

In practical terms, this means that:

  • The images can be generated and modified during a game is running
  • Colours can be changed, shapes can be drawn, all programatically
  • No AssetIds are needed

:warning: Info!
To use EditableImages in games, you need to meet requirements, set here:

Note that if you do not meet this requirements, you can still use EditableImages in a local place file.

Existing open-source libraries


CanvasDraw

The “father” of Roblox rendering libraries, CanvasDraw has been the go-to solution since its inception. As the maintainer @Ethanthegrand14 puts it:

CanvasDraw was the original library that set the standard for all others - it’s optimised, fast, and works almost anywhere. The latest builds still support legacy versions too!

OSGL

Born as a modern alternative to CanvasDraw, OSGL (Open-Source graphical library) is the minimalist cousin that trades hand-holding for raw power. From the owner (ahem, me):

Creating an EditableImage



:double_exclamation_mark: Tip
It’s also recommended to set the ImageLabel’s ResampleMode to Pixelated to avoid blurry renders at lower resolutions.

Bad examples

For now, we’ll keep what we return in Graphics.luau to a minimum, just a bare essential to get things working:

return {
    EditableImage = require(script.EditableImage)
}

… As just mentioned, in EditableImage.luau is the helper-function you saw earlier, ready for use:

Now.. onto actually using this thing…

Let’s put that helper-function to work in our main script by (finally) creating an EditableImage using this:

We’ve got our EditableImage instance, but it’s quite literally just sitting in memory. To actually show this in-game, we need to:

  1. Convert it to renderable content via Roblox’s Content
  2. Apply that Content to a UI element, such as an ImageLabel

Content” itself refers to data of an asset stored as an object within a place - almost like an AssetId that hasn’t quite been uploaded yet, but still exists. To actually render our EditableImage, it needs to become Content that can be placed on UI:

-- ... Previous code
local editableImageContent = Content.fromObject(image)
Regarding different property names

:warning: Warning
The code below does not support the upcoming SurfaceAppearance Content properties, and so may outdated after its release. This notice will be removed once the extract below has been updated when the SurfaceAppearance Content property has been made available for everyone to use.

OSGL has some handy code for detecting the correct property name given the instance. A slightly modified extract of it, from window.luau, can be found here:

And there we have it - we’ve gone from understanding what an EditableImage is, to creating one from scratch, and finally displaying it in-game through an ImageLabel. Now that we’ve got our blank canvas ready and visible, we need to actually draw to it.

Buffers


This section covers and includes:

  • What a buffer actually is (and why you should care)
  • A binary & hex crash course
  • Numeric types (u8, i16, etc)
  • RGBA Colour encoding (how 4 numbers become 1 pixel)
  • Writing a pixel to an EditableImage via a buffer

What is a buffer?

Binary format


Understanding binary and hexadecimal

Since buffers deal with binary data, first it’s important to be able to read and understand it - since we’ll be working with it a lot in the next few sections. In the following part, you’ll learn about binary and hexadecimal.

Binary

Hex

Counting to 20 in hex:

To convert between binary and hex, just group your binary digits (bits) in 4s and convert. Take the binary 1101 0111:

1101 = 13 = D
0111 = 7

This means the hex representation is D7
If you still need some practice, convert these binary numbers to hex:

1010 1100AC
0011 11113F
1001 011096

And vice versa..

1110 1011EB

0101 010155

1000 000181

Numbers

Writing to buffers efficiently and effectively

This is where it gets exciting for graphics! A typical pixel has 4 components:

  • Red (0 - 255)
  • Green (0 - 255)
  • Blue (0 - 255)
  • Alpha (Transparency / Opacity) (0 - 255)

Notice something brilliant!?

  • Each component’s range (0 - 255) fits perfectly in a u8
  • We need exactly 4 u8s (R + G + B + A)
  • 4 u8s = 32 bits = 1 u32

That means we can pack an entire pixel’s data into a single u32! This is the difference between fast and slow writing when it comes to buffers. Instead of writing 4 u8s individually, you can use a single u32 (4* faster!)

:victory_hand: Tip
Like with binary, you can also write hex digits in Luau. You prefix the number with 0x to denote it as hex:
0xFF = 255
0x80 = 128

Since 2 hex digits = 1 byte (u8), and a u32 contains 4 bytes, we represent pixels as 8-digit hex values in 0xRRGGBBAA format:

0x R R G G B B A A
   │ │ │ │ │ │ │ │
   │ │ │ │ │ │ └─└─ Alpha (u8)
   │ │ │ │ └─└───── Blue (u8)
   │ │ └─└───────── Green (u8)
   └─└───────────── Red (u8)

You can refer to more examples here in the Colors section.

Rendering and our first pixel


All that is left is writing the data into the buffer using buffer.writeu32, and rendering the buffer. writeu32 expects an offset in bytes, but we’ll write to the first pixel (offset 0) for now just as a test. We’ll also write the value 0xFF0000FF (a red pixel):

-- ... Previous code
local pixels = buffer.create(WIDTH * HEIGHT * 4)

-- Write an opaque red pixel at offset 0
buffer.writeu32(pixels, 0, 0xFF0000FF)

:double_exclamation_mark: Tip
EditableImages start rendering at (0, 0), not (1, 1). This means the most bottom-right corner has a coordinate of (width-1, height-1)

We’re not quite done - The rendering step isn’t completed yet. All we’ve done is write a value into our computers memory. The WritePixelsBuffer method of EditableImage directly writes a buffer of the given size at the given position - we will write at (0, 0) with a size of the EditableImage to write to all pixels at once:

-- ... Previous code
local pixels = buffer.create(WIDTH * HEIGHT * 4)
buffer.writeu32(pixels, 0, 0xFF0000FF)

image:WritePixelsBuffer(Vector2.zero, Vector2.new(WIDTH, HEIGHT), pixels)

If you run the game, you should now see the pixel rendered:

Let’s encapsulate this code elsewhere. First, instead of returning a “raw EditableImage”, we’ll return our own data-structure that holds the width, height, the buffer, and the EditableImage itself. Renaming the method to createCanvas, It’ll still return false if the operation to create it fails:

For syntax-sugar, we’ll also include a Render method:

To simplify this even further, we’ll create a Draw.luau module that given our EditableImageCanvas, can, for now, draw a pixel at any X or Y co-ordinate. Also, a Colour.luau would be useful for creating RGBA colours on the fly. Create the files under Graphics.luau:

LocalScript
    - Graphics.luau
        - Draw.luau
        - Colour.luau
        - EditableImage.luau

For Colour.luau, we’ll have 1 method that cleverly packs 4 u8 values into a u32 without buffers:

local Colour = {}

-- Syntax sugar
type u8 = number
type Color = number

-- Micro-optimisation
local bit32bor = bit32.bor

function Colour.fromRGBA(r: u8, g: u8, b: u8, a: u8): Color
    return bit32bor(
        a * (2^24),
        b * (2^16),
        g * (2^8),
        r
    )
end

return Colour

If you don’t understand how this works, don’t worry. It describes the method of putting 4 u8s together as a u32 in code efficiently.

In Draw.luau, we’ll declare all of our draw functions and export them in a single table:

local EditableImage = require(script.Parent.EditableImage)
local Colour = require(script.Parent.Colour)

type Colour = Colour.Colour

local Draw = {}

-- Don't use the `:` colon syntax here, so we can give
-- "self" a type of `EditableImageCanvas` :)
function Draw.pixel(self: EditableImage.EditableImageCanvas, x: number, y: number, c: Colour)
    -- We'll write this now
end

return Draw

In the pixel function, we’re effectively just taking the code from earlier and writing into the buffer at a certian position. The expression, (y * width + x) * 4, transforms an X and Y position into a index the buffer can use, so that will be the offset provided. The value written will be the Colour, and the buffer is found in our data-structure:

-- ...
-- Micro-optimisation
local writeu32 = buffer.writeu32

-- ...

function Draw.pixel(self: EditableImage.EditableImageCanvas, x: number, y: number, c: Colour)
    writeu32(self.pixels, (y * self.width + x) * 4, c)
end

Finally, export the new modules in Graphics.luau:

return {
    EditableImage = require(script.EditableImage),

    -- Add `Color.luau` and `Draw.luau`
    Draw = require(script.Draw),
    Colour = require(script.Colour)
}

In the main file, you can now reference the functions you just made and mess around. If you’re not sure what to do, you can try make a smiley face. Here’s a template with just the mouth if you’re stuck:

Template

Shapes, Textures, and more


This section includes:

  • Basic shape rendering
  • Optimised shape algorithms
  • A Roblox-specialised Texture Format & how it works + manual memory management
  • Practical applications

A basic shape


In code, that would translate to:

Here's a visual representation of what this function is doing:

:double_exclamation_mark: Info
For better performance, we can draw rectangles by copying entire rows at once using buffer.copy rather than setting pixels individually. The implementation is available in the OSGL source code here, though the details are beyond this tutorial’s scope. This optimisation significantly reduces draw operations.

Fun fact
We’ll use this same row-copying technique later when implementing our texture renderer - it’s what keeps drawing large images efficient!

For now, the code written works perfectly. We can now draw a solid rectangle with the updated Draw.luau module in our main script:

-- ... Previous code that creates the canvas

local grayColour = Colour.fromRGBA(128, 128, 128, 255)

Draw.rectangle(canvas, 1, 1, 8, 8, grayColour)
canvas:Render()

The code should correctly draw a rectangle with the given dimensions and colour, like this:

If you’re stuck, you can download a fully-commented version of we wrote, here !

Shape algorithms


When implementing shapes in a graphics system, you’ll find that most common rendering problems have already been solved through well-established algorithms. These methods have been refined over years of computer graphics development to handle both accuracy and performance efficiently.

For fundamental shapes, consider these standard approaches:

Textures


Format and compression

The module returns these two core functions that handle the texture’s lifecycle:

-- ...
get = function(): (number, number, buffer)
    if not bfr then
        bfr = buffer.create(4)
    end
        
    return 1, 1, (bfr :: buffer)
end

-- ...

The get() function implements lazy loading - it only creates the pixel buffer when you actually need it. When first called, it allocates exactly 4 bytes (enough for one RGBA pixel) and stores it. Future calls reuse this existing buffer instead of creating new ones each time. It returns three values: the width (1), height (1), and the buffer itself.

-- ...

free = function(): nil
    bfr = nil
    return nil
end

-- ...

The free() function gives you manual memory control. By clearing the buffer reference, it allows Luau’s garbage collector to reclaim that memory when needed. This becomes crucial when working with many textures or on memory-constrained devices. While simple, this actually prevents memory leaks that could gradually slow down or crash your game.

Manual memory management

Reading and rendering our format

Next up is to implement the Free and Load methods directly in our “self” table. We need to define them here because they require access to the original bfr variable from the closure:

Below is the same code, but a cleaner version without the tracking version for clarity:

Non-commented version
function Texture.new(texture: TextureFormat)
    local width, height, bfr = texture.get()
    
    local self = {
        width = width,
        height = height,
        buffer = bfr,
        
        Free = function()
            bfr = nil
            texture.free()
        end,
        
        Load = function()
            _, _, bfr = texture.get()
        end,
    }
              
    return self
end
-- ... Previous code

local EditableImage = require(script.Parent.EditableImage)

-- ...

function Texture.draw(canvas: EditableImage.EditableImageCanvas, texture: CanvasTexture, x: number, y: number)
    -- Texture drawing will be implemented here.
end

-- ... Rest of the code

I previously mentioned how drawing Textures will be handled near the end of the “A basic shape” section. The function that we’ll write will properly handle all the edge cases you’d encounter in real-world use, whilst implementing the optimisations mentioned there.

-- ...

local xOffset = if x < 0 then math.abs(x) else 0
local yOffset = if x < 0 then math.abs(y) else 0

-- ...

The first piece, shown above, will handle negative coordinates. If x is negative, xOffset will become positive, meaning we skip that many pixels from the left of the picture. This is the same for yOffset. This essentially clips the texture if it has negative coordinates.

This temporarily will “crop” the texture to ignore the clipped-off parts caused by a negative draw position.

:double_exclamation_mark: Info
This mutates the texture’s dimensions temporarily. It’s fixed later.

-- ...

local drawWidth = math.min(texture.width, canvas.width - x - xOffset)
local drawHeight = math.min(texture.height, canvas.height - y - yOffset)

-- ...

The snippet above compute how much of the texture we can draw without going off the right or bottom edges of the canvas (dimensions larger than the size of the canvas).

-- ...

if drawWidth <= 0 or drawHeight <= 0 then
    return
end

-- ...

The last piece of bounds-checking code before actual drawing; this aborts early if there’s nothing to draw (fully clipped, or out of bounds)

-- ...

local targetBuffer = canvas.pixels
local sourceBuffer = texture.buffer
local targetSize = texture.width * 4

-- ...

Next, both buffers are prepared (as we’ll be copying from one to another). We multiply targetSize by 4 as 4 bytes make up a single pixel.

-- ...

local maxY = if y + drawHeight - 1 > canvas.height
    then canvas.height - y - 1 
    else drawHeight - 1

-- ...

This line determines how many rows of pixels we can draw safely, if drawing would go beyond the bottom of the camvas, it’ll clip it.

-- ...

for dy = yOffset, maxY do
    -- We'll cover what's in this next :3
end

-- ...

As aforementioned, instead of looping through every pixel, instead loop through each row (keeping in mind where the texture is being clipped)

-- Inside the loop shown above ^^^
-- Don't worry if this looks scary, once you look
-- at each line individually it'll make much more sense :)
buffer.copy(
    targetBuffer, -- <- What buffer is being written into
    ((textureY + dy) * canvas.width + x + xOffset) * 4, -- <- The offset of where to begin pasting (where the row should be)
    sourceBuffer, -- <- What to copy from (the tetxure)
    (dy * (texture.width + xOffset)) * 4, -- <- The offset of where to begin copying (where the row starts on the texture) 
    drawWidth * 4 -- <- How many bytes to copy (*4 because 1 pixel = 4 bytes)
)

This, is what copies each line from the texture onto the canvas, obeying where the texture is being clipped using the buffer.copy function.

texture.width += xOffset
texture.height += yOffset

Outside the loop, finally the textures original dimensions are restored; the cropping done is undone and the texture has been drawn into the canvas buffer. You can find this exact implementation in OSGL, here!

This makes the full (uncommented) function look like:

Full uncommented function

Now that the Texture class has been completed, the last step is exposing it through our main Graphics.luau module. We’ll add it to Graphics.luau alongside our other core components:

return {
    EditableImage = require(script.EditableImage),
    Draw = require(script.Draw),
    Colour = require(script.Colour),
    Texture = require(script.Texture) -- <- Add this!
}

To see our texture system in action, I’ve prepared a sample texture module that follows our format specification. Before using it, there are a few important notes:

  • Import the file as a ModuleScript named myTexture under your main LocalScript
  • Avoid opening the texture module directly - it contains raw bitmap data that will almost definitely crash Studio (~16*10^2+ character strings)
  • If using “Insert from file”, you’ll need to convert it to a ModuleScript (Reclass is a free tool that can help with this)

Under the main script, import the new Texture class and use the functions we wrote earlier to render the texture to the screen:

If all worked correctly, you should get something that looks like this:

A face with its toungue sticking out! :squinting_face_with_tongue: :squinting_face_with_tongue: :squinting_face_with_tongue:
If you need help, you can download everything we’ve done here

Videos and fonts?


With a working texture renderer, we can use it for way more than just static images. Textures are the foundation of almost everything in graphics, and with a few clever tricks, we can extend this system to handle fonts and even video playback!

Font rendering

Video playback

…we could easily feed it frame data from a video decoder and render it smoothly. Infact, people have already done something similar! Another notable example can be found here. It all boils down to putting the right pixels in the right places at the right times - after all, the right pixel in the wrong place can make all the difference in the world.. heh. (please tell me someone got the reference!)

Multithreading


Let’s discuss Parallel Luau - Roblox’s approach to multithreading. First, some honest truths: this isn’t as straightforward as regular Luau. The documentation is… eh, best practices are still developing, and frankly, it can be confusing as hell when you’re starting out. If this doesn’t make sense immediately, don’t worry - it’s the sort of thing that becomes clear when you actually use it.

(Fair warning - this might not always be faster for simple tasks due to the overhead, but for expensive operations like raycasting, the parallel approach really works well. The key here is learning how to wield Parallel Luau effectively.)

Parallel Luau


Parallel Luau works by dividing computational tasks (or “jobs”) across different execution contexts called actors (AKA. “workers”). Instead of processing everything in sequence, the system distributes the workload among these independent actors that can operate simultaneously. Each worker handles its assigned chunk of work separately, allowing code to take proper advantage of multi-core processors.

Well, what is a worker anyway?

Workers


Since it’s a class, it’ll contain this basic code for now to help us get started:

local ParallelManager = {}

function ParallelManager.new()
    
end

return ParallelManager

It’ll handle the tedious work of creating each Actor instance, configuring it for parallel execution, and keeping track of all our workers. For simplicity, I’ll create a type, ParallelSettings, that defines the list above in terms of Luau code:

local ParallelManager = {}

export type ParallelSettings = {
    workerScript: BaseScript, -- What script will become the worker
    workerAmount: number, -- How many of these workers we want
    width: number, -- The width of the image
    height: number, -- The height of the image
    image: EditableImage -- What image we're rendering to
}

-- ... Rest of the code

We’ll require ParallelManager.new to take these settings so it knows exactly how to set up our parallel environment:

-- ... Previous code

function ParallelManager.new(settings: ParallelSettings)
    -- Create actors, setup rendering
end

-- ... Rest of the code

Before anything, the first thing the function will do is calculate how many rows each actor will be assigned. Also, we’ll sanitise the input - You can only have height amount of workers (since that would be 1 worker per row)!

-- ... Previous code

function ParallelManager.new(settings: ParallelSettings)
    local workerAmount = settings.workerAmount
    local width, height = settings.width, settings.height
    local workerScript = settings.workerScript
    local image = settings.image -- <─ We'll use this later
    
    --                   ┌─> This process is called "sanitisation"
    --                   │   It involves making sure values are put
    --                   │   within a range, if not already, to
    --                   │   prevent erroneous code
    -- ──────────────────┴──────────────────┰────┰────>   Minimum 1 actor, maximum
    workerAmount = math.clamp(workerAmount, 1, height) -- `height` actors
    
    --      ┌─> How many rows each actor gets (not accounting for left over rows!) 
    -- ─────┴────────────────────────┰─────────────────┐
    local baseRowsPerActor = height // workerAmount -- └> A lesser known operation,
    local leftoverRows = height % workerAmount      --    floor division. Equivilent
    -- ─────────────────────────┴─> The modulo is   --    to `math.floor(height / workerAmount)` 
    --                              the remainder
    --                              of a division. (how many are left over)     
end

-- ... Rest of the code

With that row calculation out of the way, we’ll now create and configure the worker Actors. The process starts by instantiating each Actor and preparing its initialisation data; this includes the specific range of rows it will handle (startY to endY), the full screen width and the image it will render to. We shall use Roblox’s Actor messaging API to send this configuration data to each worker in a structured format we’ll deal with later.

Actors must be parented to an object to run parallel code, so we’ll parent it under the original script that holds the original actor template. We don’t need to worry about where that is right now. To allow communication later on, also store each actor in an array:

-- ... Previous code

local actors: {Actor} = {} -- <─ Actors stored in here
local originalParent = workerScript.Parent -- <─ Actors will be parented under here
    
    -- For each worker we want to create:
for i = 1, workerAmount do
    local actor = Instance.new("Actor")
    actor.Parent = originalParent -- <─ Make sure to parent the actor or it'll error
    workerScript:Clone().Parent = actor -- <─ Put a new instance of a worker under the actor
    table.insert(actors, actor) -- <─ Register the actor in an array, we'll refer to it later
end

-- ... Rest of the code

:warning: Warning
This assumes the worker’s parent is a place where it can execute (best under StarterCharacterScripts or the players character). If not, the worker will never run.

Now we need to actually set up our worker Actors to do their jobs. We’ll send each one an “Initialise” message (yes, British spelling, sorry Americans) containing their specific configuration. This is like handing each construction worker their blueprint before the job actually starts.

-- ... Previous code

task.wait() -- <─ Actors sometimes take extra time to setup
    
-- Distribute each row, accounting for
-- leftovers.
local currentRow = 0
for i, actor in actors do
    local rowsForThisActor = baseRowsPerActor
    if i <= leftoverRows then
        rowsForThisActor += 1 -- Distribute any extra rows
    end

    --                                              ┌─> Starting row
    --       ┌─> Use the Actor messaging API        │     Amount of rows <─┐
    -- ──────┴───────────────────────┰───────┰──────┴──────────────────────┴─>     Any information the 
    actor:SendMessage("Initialise", width, image, currentRow, rowsForThisActor) -- actor needs.
    currentRow += rowsForThisActor
end

-- ... Rest of the code

The rest works simply - every frame, we’ll send a fresh “Update” message to all our workers, telling them what needs to be drawn this frame. This message will include any changes from the previous frame and which screen areas require updates.

local RunService = game:GetService("RunService")

-- ... Previous code

RunService.PreRender:Connect(function()
    -- ─────┰─> Loop through each actor
    for _, actor in actors do
        -- ──> Tell the actor to update.
        --     No data needs to be sent,
        --     it already has all the data
        --     from the previous step :)
        actor:SendMessage("Update")
    end
end)

-- ... Rest of the code

And with that, our ParallelManager.luau is nearly complete. We’ll just add two final helper functions to handle the worker communication properly - think of these as giving our workers their instruction manuals.

The first sets up the initialisation handler, letting each worker prepare its own workspace when it receives that “Initialise” message we talked about earlier. The second handles the frame updates, telling workers when to actually draw each frame. Here’s how simple the interface ends up being:

-- "Teaches" workers what to do when they first start up
function ParallelManager.workerInit(actor: Actor, callback: (width: number, image: EditableImage, currentRow: number, rows: number) -> ())
    actor:BindToMessageParallel("Initialise", callback) 
end

-- "Tells" workers when to.. well work
function ParallelManager.workerUpdate(actor: Actor, callback: () -> ())
    actor:BindToMessageParallel("Update", callback)
end

You can find a full, commented version of ParallelManager.luau, here!

All that’s left now is actually writing the rendering code that makes this whole system draw something useful. But the hard work of building the parallel infrastructure is done. We’ve got our manager, all that is left is making the worker.

A worked example


Get it? Worked example? Because we’re writing the… you know what, nevermind.

Now that we’ve built our parallel rendering system, it’s time to put it to work - quite literally. We’ll write the actual rendering code that our workers will execute each frame. The goal is simple: to write a circle drawing algorithm and distribute it across all our workers, with each one handling its assigned screen region.

It can’t be that hard, right?

Initialisation

We’ll place the worker, a which should be a LocalScript under our main LocalScript, and call it Worker.luau - .

The first thing will be making sure that the worker doesn’t do anything else other than run as our worker (since right now, it’s a general script attatched to no actor):

local actor = script:GetActor()
if not actor then return end

The GetActor method of script returns whether the current script is associated with an Actor, and if it is, returns it, else, returns a nil value. We use this to our advantage. When this script becomes parented to our Actor (As ParallelManager.luau handles that), the actor will no longer be nil, meaning the actual worker code that we’re about to write will run.

The worker will require ParallelManager.luau and use those two helper functions to register events:

-- ... Previous code

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local ParallelManager = require(ReplicatedStorage.ParallelManager)

-- On initiated
ParallelManager.workerInit(actor, function(width: number, image: EditableImage, currentRow: number, rows: number)
    -- We're going to write this here code now!
end)

-- On render
ParallelManager.workerUpdate(actor, function()
    
end)

Before we get to the rendering code (the fun part!), let’s finish off the intitiation stage by creating a buffer for the part we’re rendering to, and 2 Vector2s - one for the size of the area we’re rendering to, and one for the position. We’re still using that WritePixelsBuffer function from earlier!


-- ... Previous code

local WIDTH: number, IMAGE: EditableImage
local screenBuffer: buffer
local size: Vector2
local position: Vector2

local centerX: number
local centerY: number
local radiusSquared: number
local startY: number

-- On initiated
ParallelManager.workerInit(actor, function(width: number, image: EditableImage, currentRow: number, rows: number)
    -- Set globals
    WIDTH, IMAGE = width, image
    
    local pixelCount = width * rows
    local bufferSize = pixelCount * 4 -- <- Each pixel is 4 bytes.
    
    -- Set the rest of the globals
    bfr = buffer.create(bufferSize)
    size = Vector2.new(width, rows)
    position = Vector2.new(0, currentRow)
    startY = currentRow

    -- The following are needed for our cirle calculation later :)
    local height = image.Size.Y
    centerX = width / 2  -- ──┬─> Used for positioning
    centerY = height / 2 -- ──┘
    
    local radius = math.min(width, height) * 0.4 -- 80% of the smallest dimension
    radiusSquared = radius^2
end)

-- ... Rest of code

That’s literally all there is to the initialisation - we’re just setting up some global variables the rest of our worker will need.

(Yes, using globals here is perfectly fine - each worker runs in its own isolated environment, so we don’t need to overcomplicate things with proper encapsulation. Sometimes the simple solution is the right one.)

We’ll use these variables in our rendering code. The bfr buffer in particular is essentially our worker’s private scratch space for pixel operations before we commit changes back to the main image.

Rendering

With that out the way, it’s time to start rendering. To draw our circle, we’re using a brute-force method which tests if each pixel lies in a circle by checking a condition, (x^2 + y^2 <= r^2):

You’ll see something like this ^^ If you put it in any sort of graphing calculator
Now you might understand why we made that radiusSquared variable earlier :wink:

We’ll iterate through every pixel in our assigned sector, testing each one against the circle equation, x^2 + y^2 <= r^2. Pixels that pass this test get colored red - for examples sake.

-- ... Previous code

local RED = 0xFF0000FF

local floor = math.floor

-- For examples sake, hardcode it to red. This is the same
-- function we wrote earlier in the `Draw.luau` module.
local function pixel(x: number, y: number)
    buffer.writeu32(bfr, (y * WIDTH + x) * 4, RED)
end

-- ... Initialisation code

ParallelManager.workerUpdate(actor, function()
    -- Draw only pixels inside the circle

    -- [Outer loop]
    -- Start at 0. We'll correct this in a second
    for y = 0, size.Y - 1 do
        -- Get the absolute Y position
        local absoluteY = y + startY

        -- [Inner loop]
        -- Go through the entire row (0 -> WIDTH - 1)
        for x = 0, WIDTH - 1 do
            -- Get the correct positions (the circle is centered)
            local dx = x - centerX
            local dy = absoluteY - centerY

            -- Apply x^2 + y^2 <= r^2
            if dx^2 + dy^2 <= radiusSquared then
                -- Make sure not to draw at decimal coordinates!
                pixel(floor(x), floor(y))
            end
        end
    end
end)

The final step in our Worker script is finally writing back to the original EditableImage using WritePixelsBuffer. The function, however, cannot be ran in parallel (like many functions, you’ll find). You can use task.synchronize to make the following code run in serial:

-- ... Previous code

task.synchronize()
IMAGE:WritePixelsBuffer(position, size, bfr)

-- ... Rest of the code

That’s it! All that’s needed now is to create a new ParallelManager instance in the main LocalScript. If you need the source to the code above, you can find it here:

Semi-commented code
local actor = script:GetActor()
if not actor then return end

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ParallelManager = require(ReplicatedStorage.ParallelManager)

local WIDTH: number, IMAGE: EditableImage
local bfr: buffer
local bytes: number
local size: Vector2
local position: Vector2

local centerX: number
local centerY: number
local radiusSquared: number
local startY: number

local floor = math.floor

local RED = 0xFF0000FF

-- On initiated
ParallelManager.workerInit(actor, function(width: number, image: EditableImage, currentRow: number, rows: number)
    WIDTH, IMAGE = width, image

    local pixelCount = width * rows
    local bufferSize = pixelCount * 4

    bfr = buffer.create(bufferSize)
    size = Vector2.new(width, rows)
    position = Vector2.new(0, currentRow)
    bytes = bufferSize
    startY = currentRow

    local height = image.Size.Y
    centerX = width / 2
    centerY = height / 2
    
    local radius = math.min(width, height) * 0.4 -- 80% of the smallest dimension
    radiusSquared = radius * radius
end)

local function pixel(x: number, y: number)
    buffer.writeu32(bfr, (y * WIDTH + x) * 4, RED)
end

-- On render
ParallelManager.workerUpdate(actor, function()
    -- Draw only pixels inside the circle
    for y = 0, size.Y - 1 do
        local absoluteY = y + startY
        for x = 0, WIDTH - 1 do
            local dx = x - centerX
            local dy = absoluteY - centerY
            if dx^2 + dy^2 <= radiusSquared then
                pixel(floor(x), floor(y))
            end
        end
    end

    task.synchronize()
    IMAGE:WritePixelsBuffer(position, size, bfr)
end)

To actually use what we just made is dead simple. In the main LocalScript, just require the ParallelManager that we created and call its constructor with the correct arguments:

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local graphics = require(script.Graphics)
local EditableImage = graphics.EditableImage

local ParallelManager = require(ReplicatedStorage.ParallelManager)

local imageLabel = script.Parent

local WIDTH, HEIGHT = 50, 50

-- Create the canvas
local success, canvas = EditableImage.createCanvas(WIDTH, HEIGHT)
if not success then
    error("Failed to create EditableImage: Invalid dimensions or service unavailable")
    return
end

local editableImageContent = Content.fromObject(canvas.editableImage)
imageLabel.ImageContent = editableImageContent

ParallelManager.new({
    workerScript = script.Worker, -- <-- The worker script
    width = WIDTH, -- <-- Width of the image
    height = HEIGHT, -- <-- Height of the image
    image = canvas.editableImage, -- <-- The `EditableImage`
    workerAmount = 5 -- <-- How many workers*
})

:double_exclamation_mark: Info
More workers does not always mean higher performance. It varies for each device, try a few values and see which yields the heighest FPS. To see your FPS in studio, enable the Show Diagnostics Bar setting in “Studio settings” to see your FPS in the bottom-right corner.

For the (hopefully) final time, if you run the game, a crispy-red circle should be rendered on your screen using Parallel Luau! If everything worked, congratulations! You’ve just built a distributed rendering system that properly utilises multiple workers. Not too shabby for what started as some simple pixel manipulation.

(If it’s not working, don’t panic - double check your output and make sure those message handlers are properly bound. Parallel programming is tricky, but you’ll get it. If you’re still stuck, feel free to send a message under this thread, or to PM me)

Want more like this?


If you found this guide helpful and would like to see similar deep-dives on other topics, reply to this thread or PM me! I’m always open to suggestions. Happy programming!

(No promises, but I’ll definitely consider ideas!)


The following people have requested to be notified upon this posts release:

@nix102on @enemyfunction

31 Likes

Thanks, I appreciate the time this took you to write this, really good. And your explanation.

4 Likes

That was the longest and most detailed post I’ve ever seen. Thank you so much!

6 Likes

Awesome resource!

I’ve noticed a typo, I think you meant actor, not ctor.

2 Likes

Thank you for finding this typo! (It’s quite hard to find such things in such a long post :sob:) I’ve gone ahead and corrected it. Thanks once again!

1 Like

Awesome post! Thanks for the link to my video player post, allow me to go into more detail here.

The way users picked out the videos itselves, was a custom module I wrote based off of Damon Wongs youtube-search-api. The thumbnails were put through a JPEG to PNG api, and then decoded on Roblox’s side using MaximumADHD’s PNG library.

The video rendering itself was done via a Node.JS backend, where roblox would send a request to get a video via its ID, the backend would download that video using yt-dlp, and, using FFMPEG would encode the last 3 frames as an RGBA buffer, and send that to roblox via HTTP requests.

Why 3 frames you may ask? Well, the roblox HTTP request limit is 500 requests/min. If you do the math, that comes out to at max 8 requests per second. If you get 3 frames per each request, 8*3 is 24, so the video would render at 24 FPS. The roblox side would then split the frame data into the 3 different frame buffers, and render it onto the editableimage.

1 Like

goodjob, really helped with mirroring my terrain generation into 2d

1 Like

How would I make a transparent pixel? From colors 0xFFFFFF00 to 0xFFFFFFF0 they’re translucent, but afterwards they become opaque starting as cyan and going to white

Nvm, I was inputting the hex values incorrectly…