OSGL - EditableImage graphics library

v1.32b, @saaawdust, @jukepilot, @msix29

:warning: Update to the latest version of OSGL here!

Note: OSGL currently does not work in live games, as it uses EditableImages. Until EditableImages release, this can only be used in studio.

What is OSGL?

OSGL (Open-Source Graphical Library) is designed to offer developers a straightforward and efficient way to read and write PixelData to EditableImages.

Originally created as an alternative to CanvasDraw, a graphics-library specifically built for Roblox, OSGL focuses exclusively on providing a robust framework for working with EditableImages.

Please note that OSGL is currently in a “beta” phase. While many features are planned, they are yet to be implemented. Your feedback and contributions are invaluable as we continue to enhance the library!

So, what can OSGL do?

OSGL provides users with a “Window”, also known as an EditableImage, that allows for drawing a variety of elements including shapes, textures, and text. Key features of OSGL include:

  • Shapes: Allowing you to easily create and manipulate various geometric shapes.
  • Textures: With support for a wide array of image formats!
  • Built-in Error Handling: OSGL comes with its own error handler, catching and managing common (and annoying) EditableImage errors!

This library is a resource for developers looking to create pixel-based 2D or 3D games, as well as for projects that require efficient storage and manipulation of Pixel Data (for example, a drawing game!)

Examples

OSGL v1.2b and v1.3b currently have no examples.

OSGL v1.1b Examples

144P Shrek, sampled at 15FPS:

Minecraft Wireframe 3D Renderer (60FPS):
note: sadly I couldn’t find the RBXM file so I cannot record a video :slightly_frowning_face: . But here are some images:

image image image

Live shading and rotating render:
image

Download OSGL

You can download the latest version from the github here, or from Roblox.

Quick tutorial

:warning: This tutorial assumes you have a basic understanding of Luau. If you’re new, you can find plenty of resources online!

You can learn more on the Docs.
The following tutorial will show you how to draw a circle to the screen.

Creating & Opening the window

Before we can start drawing on a window, we need to create one. Place the OSGL module somewhere, such as ReplicatedStorage/Packages. Create an ImageLabel in StarterGui, with its BackgroundTransparency set to 0. This ImageLabel will serve as your canvas:

:warning: Lower resolutions cause blurred images! If you’re rendering at a low resolution, set the ResampleMode property of your ImageLabel to Pixelated!

Additionally, create a LocalScript in your desired location (e.g., StarterPlayer/StarterPlayerScripts ) and name it appropriately. This script will create our window and manage the rendering process.

OSGL is split into sub-modules that do different tasks. The sub-module we’re looking for is the Window class, which handles creation of windows:

local OSGL = require(ReplicatedStorage.Packages.OSGL)
local Window = OSGL.Window

There are two functions available for creating our window: Window.from and Window.new . According to the API:

  • Window.from : Creates an OSGL window from an existing EditableImage.

  • Window.new : Creates an OSGL window by initializing a new EditableImage instance at the specified location.

Since we don’t have an existing EditableImage , we’ll use Window.new to create our window on the designated ImageLabel :

local OSGL = require(ReplicatedStorage.Packages.OSGL)
local Window = OSGL.Window

local windowUi = -- *reference to windowUi*

-- Create our window
local myWindow = Window.new(windowUi, { sizeX = 500, sizeY = 500 })

The example above creates an OSGL window, with a size of 500x500, on windowUi. You can find more details about this function in the API.

Rendering to the Window

Great, now we have our window, but now we need to render to it, but only if it hasn’t been destroyed by some external script! OSGL provides a handy method, IsOpen, that returns true while the window exists.

while myWindow:IsOpen() do
	
end

IsOpen will automatically yield, so there’s no need for task.wait here. We can focus directly on rendering!

To draw a circle, we will use the draw sub-module, which allows us to render shapes directly to the provided double-buffer (meaning the rendering is not displayed on screen immediately). In addition, we need to use the color module (which accepts RGBA values in the range of 0 to 255) to set a color for our window. We’ll use the default RED color for this example:

local OSGL = require(ReplicatedStorage.Packages.OSGL)
local Window = OSGL.Window
local draw = OSGL.draw
local color = OSGL.color

local windowUi = -- *reference to windowUi*

-- Create our window
local myWindow = Window.new(windowUi, { sizeX = 500, sizeY = 500 })

-- Automatically yields for a heartbeat, so task.wait isn't needed!
while myWindow:IsOpen() do
    draw.circle(myWindow, {
		centerX = 250,
		centerY = 250,
		fillColor = color.RED,
		radius = 200

        -- Strokes are optional, so we don't need to
        -- pass `strokeColor` and `strokeThickness`
	})
end

The first argument for every drawing function should be your window. In this code, we create a circle centered at (250, 250) with a radius of 200 in RED (255, 0, 0, 255).

After running this code, you may notice that nothing seems to happen (aside from a slight performance drop :sob:). This is because we have only drawn to the buffer, and not yet to the screen. To display the contents of the buffer, we can use the Render method of the Window class:

local OSGL = require(ReplicatedStorage.Packages.OSGL)
local Window = OSGL.Window
local draw = OSGL.draw
local color = OSGL.color

local windowUi = -- *reference to windowUi*

-- Create our window
local myWindow = Window.new(windowUi, { sizeX = 500, sizeY = 500 })

-- Automatically yields for a heartbeat, so task.wait isn't needed!
while myWindow:IsOpen() do
    draw.circle(myWindow, {
		centerX = 250,
		centerY = 250,
		fillColor = color.RED,
		radius = 200

        -- Strokes are optional; no need to provide
        -- strokeColor and strokeThickness
	})

    -- Renders the double-buffer to the screen
    myWindow:Render()
end

image

Now everything should work beautifully! You have successfully rendered a circle in OSGL!

Full Code
--!optimize 2
-- ^^ This flag optimises 
-- the script in studio

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local OSGL = require(ReplicatedStorage.Packages.OSGL)
local Window = OSGL.Window
local draw = OSGL.draw
local color = OSGL.color

local windowUi = -- *Reference to windowUi*

-- Create our 500x500 window
local myWindow = Window.new(windowUi, { sizeX = 500, sizeY = 500 })

-- While the window exists,
-- render every heartbeat.
while myWindow:IsOpen() do
	
	-- Draw a circle at 250, 250, with a radius of 200, with a red color.
	draw.circle(myWindow, {
		centerX = 250,
		centerY = 250,
		fillColor = color.RED,
		radius = 200
	})
	
	-- Render our circle to the screen.
	myWindow:Render()
end

print("The window has been destroyed!")

Contribution

OSGL is an open-source project, and we welcome your contributions! You’re encouraged to edit the source code and share your ideas. Feel free to participate via GitHub or on the DevForum. Your involvement is greatly appreciated!

Credits

While you do not need to credit me for using OSGL, acknowledging the original creator is always appreciated but entirely optional. You are free to modify the source code as you wish, but please refrain from reuploading or claiming the asset as your own work.


42 Likes

How’s the performance of your examples? For me, it would depend on the performance of this and CanvasDraw when choosing which to use.

6 Likes

I don’t have the examples as shown - sadly I don’t have them

any more :sob: but i can compare with refreshing the screen.

Not really sure how to measure performance but I can show the micro-profiler for both.
Rendering every second on OSGL gives:

Code
local OSGL = require(script.OSGL)
local enum = OSGL.enum
local window = OSGL.window
local color = OSGL.color

local myWindow = window.new({
	Size = enum.WindowSize.Mutuable,
	Instance = script.Parent
})()

while myWindow:isOpen() do
	myWindow:clear(color.Black)
	
	myWindow:render()
end

CanvasDraw:

Code
local CanvasDraw = require(script.CanvasDraw.FastCanvas)

local canvas = CanvasDraw.new(1024, 1024, script.Parent)

canvas:SetClearRGBA(0, 0, 0, 1)

while task.wait() do
	canvas:Clear()

	canvas:Render()
end

My module unlike canvas draw also has inbuilt shapes and stuff etc, but it seems that from the example above that mine is faster?

3 Likes

yo @Ethanthegrand14 check this out!

3 Likes

I tried out OSGL (Open Source Graphics Library), and I’m amazed at how fast it is. I rendered this wireframe scene from scratch, and it ran at a smooth 60 frames per second without any hiccups.

For context, I created a landscape and a tree, similar to what you’d see in Minecraft, with all the blocks in place. Usually, something this detailed would cause some lag, but OSGL handled it effortlessly. The rendering was fast and the performance stayed consistent. You could rotate around the scene too.

I’m genuinely impressed with how powerful OSGL is. So, check it out before you doubt. It’s perfect for high-quality, complex projects.

image

8 Likes

This is really impressive!

One question though. How on earth did you get clearing to be so fast?

My current method involves cloning an RGBA array table and setting that my grid variable. I am not sure how I could make it any faster. Are you caching your grid with fixed colours or something?

function Canvas:Clear()
	Grid = table.clone(ClearingGrid)
end

Gonna have to stop you on this bit. CanvasDraw definitely has inbuilt shapes n stuff too. CanvasDraw is a graphics library. It has line, polygon, shape, and advanced image rendering and sampling, and quite a bit more too!

As far as speeds go, you’re clearing code seems to be infinitely faster. It seems like you are caching your end result or something? Cause I have no idea how you are getting it to be so fast.


Also, im trying to figure out the documentation, im following your shape section in your tutorials, but im struggling to draw a pixel.

local OSGL = require(script.OSGL)
local enum = OSGL.enum
local window = OSGL.window
local shape = OSGL.shape
local color = OSGL.color

local myWindow = window.new({
	Size = enum.WindowSize.Maximum,
	Instance = script.Parent
})()

local function GetColour()
	return color.new(math.random(0, 1), math.random(0, 1), math.random(0, 1), 1)
end

while myWindow:isOpen() do
	for X = 1, 1024 do
		for Y = 1, 1024 do
			myWindow:draw(shape.pixel(Vector2.new(X, Y), GetColour()))
		end
	end

	myWindow:render()
end

Oh btw, u should try doing a per-pixel rendering test. Im curious to see how performance goes for you.

CanvasDraw can achieve around 8 FPS at 1024x1024 with random pixels every frame.

Code:

local CanvasDraw = require(script.CanvasDraw)

local Canvas = CanvasDraw.new(script.Parent, Vector2.new(1024, 1024), Color3.new(0, 0, 0))
Canvas.AutoRender = false

while true do
	for X = 1, 1024 do
		for Y = 1, 1024 do
			local R, G, B = math.random(0, 1), math.random(0, 1), math.random(0, 1)
			Canvas:SetRGB(X, Y, R, G, B)
		end
	end
	
	Canvas:Render()
	task.wait()
end
5 Likes

Since you are parodying the OpenGL name you should include parallel processing for each row of the Image (per pixel is not ideal).

I know there is already multiple modules for parallel processing but since the biggest use case for this is either ray tracing or some other rendering experiments. Added proper support for parallel luau would be cool.

4 Likes

A few hours after writing that I realised so :sob:

As in, what I meant was in your FastCanvas module I couldn’t find any functions for shapes - I see that you just use normal CanvasDraw in your examples - Was I doing something wrong with using FastCanvas?

I tested using a large array and storing that array in memory seemed to be very laggy, so I took this method instead:

It may give laggy render times, but it does seem to be faster?

As for drawing a pixel, it was one of the basic features I forgot to add / document, lol :sweat_smile:
In this case, CanvasDraw is faster. Right now OSGL is making a huge array storing each pixel data and then writing to the screen. I’ll be fixing this next time.
Rendering every 2 seconds gives this horrible result:

sorry, it’s 5am. The reason it was so laggy was because the screen isn’t been cleared, meaning a huge array (1024^2) was being created every second. With that addition, OSGL seems to yield around the same result as CanvasDraw?

As for your code, the “color” module uses RGBA values 0 - 255. Not 0 - 1!

local OSGL = require(script.OSGL)
local enum = OSGL.enum
local window = OSGL.window
local shape = OSGL.shape
local color = OSGL.color

local myWindow = window.new({
	Size = enum.WindowSize.Maximum,
	Instance = script.Parent
})()

while myWindow:isOpen() do
	myWindow:clear(color.White)
	
	for X = 0, 1023 do
		for Y = 0, 1023 do
			myWindow:draw(
				shape.pixel(Vector2.new(X, Y), color.random(nil, nil, nil, 0))
			) 
		end
	end
	
	myWindow:render()
end

The only way to make the code above more efficient would be to store the pixel data in an array and unpack it, or to use custom rendering methods:
another note, this is even faster!!

local OSGL = require(script.OSGL)
local enum = OSGL.enum
local window = OSGL.window
local shape = OSGL.shape
local color = OSGL.color

local myWindow = window.new({
	Size = enum.WindowSize.Maximum,
	Instance = script.Parent
})()

--// stolen from canvas draw lol, sorry!!
local function GetGridIndex(X, Y, Width)
	return (X + (Y - 1) * Width) * 4 - 3
end

local fillscreen = shape.register("random", function(): { Command: string, Data: { [any?]: any? } } 
	return {
		Command = "random",
		Data = {}
	}
end, function(Window, self) 
	local array = table.create(Window.Size.X * Window.Size.Y * 4, 1)
	
	for Y = 0, Window.Size.Y-1 do
		for X = 0, Window.Size.X-1 do
			local i = GetGridIndex(X, Y, Window.Size.X)

                        -- pretty sure math.random is faster? idk
			local random = Random.new()
			array[i] = random:NextInteger(0, 255)
			array[i + 1] = random:NextInteger(0, 255)
			array[i + 2] = random:NextInteger(0, 255)
		end
	end
	
	
	Window.Renderer:WritePixels(Vector2.zero, Window.Size, array)	
end)

while myWindow:isOpen() do
	myWindow:clear(color.Black)
	
	myWindow:draw(
		shape.random()
	)
	
	myWindow:render()
end
2 Likes

Alrr, I tested your code and I get around 2 to 3 FPS (at what should be 1024x1024). Also your random colouring doesnt work correctly for some reason.

So this is slower than CanvasDraw’s :SetRGB() method, which ran around 7 to 8 FPS at the same resolution at the same update rate with math.random


I think your main issue is your use of constructing Vector2s, Which i discovered is pretty damn slow

2 Likes

Pretty sure Vector2s are slower than passing numbers? I’m not sure. I’m working on the next version and I’ll see if I can decrease this time :+1:

2 Likes

try avoiding constructors and use either buffers or raw numbers/tables.
Because I saw it myself ColorSequences, NumberSequences, Vectors and all that are a lot slower to access and construct.
When I was making my own project via editable images, I’ve had to replace color/number sequences and vectors and other things with buffers and raw numbers, which drastically improved performance (by x4 or more) (although constructing Vector2’s in WritePixels is inevitable)

2 Likes

He never misses, The absolute legend :star_struck:

2 Likes

This is really great! The ability to use text & custom fonts is going to save me so much time. One question though, could there be some sort of tutorial on how to add custom fonts? I read the docs, but I can’t understand what the instructions are trying to say.

2 Likes

The custom fonts are bitmap fonts (0 represents nothing, 1 represents a pixel) meaning they cannot be resized.

porting a font from online

As for making custom fonts, it’s pretty easy. Take a fontmap, lets take this one:


Cut out each individual letter and save it as a PNGs in a folder.
Then, write a program that goes through each one of these PNGs. it should read the image data, and generate a text output in the style of a lua table. If the colour is transparent, it should be “0”. If there is a pixel, it should be “1” (bitmap fonts). It should make a huge array, something like this:

You can add the array directly to the module (Window > Render > Font), OR, add it during runtime with the function provided.

making your own font

all the bitmap font is, is a dict. it should contain each letter you want. lets say we wanted a 4x4 lowercase letter ‘a’, that could be:

["a"] = {1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1}

Every 4 pixels would be a single line, making that construct:

image

as our letter “a”. OSGL handles the rest.

2 Likes

What happens when you use this module after the nested for loop?


Making Vector2’s are slower because it’s constructing an object, assigning a metatable and whatnot… while passing 2 numbers doesn’t do any of that.

3 Likes

This looks very interesting. I will try this out!

2 Likes

I see, I think I understand

Is there an already existing program that does this that I can use? If not, how can I make it? (or if possible, could you send me the one you used?) I don’t know how to write one myself

1 Like

the problem is using an interpreted language to access & set pixels from an array (1024 * 1024 * 4) times per iteration

it doesn’t matter of which graphic library people chose on here – the speed of the language is ultimately what holds all of them back

even when optimizing the nested for loops to be more predictable to the cpu:

-- LargeCanvas is my internal library to allow larger resolutions with EditableImages

-- start timing the loop here with os.clock()
for Y = 0, 1024-1 do
	for X = 0, 1024-1 do
		local R, G, B = math.random(0, 1), math.random(0, 1), math.random(0, 1)
		LargeCanvas:PutPixel(X,Y,R,G,B)
	end
end
-- end timing the loop here ~>  0.4-0.39 second per iteration
-- in other words, 2.5 fps

-- i now realize that there is a overhead when determining 
-- which editableimage to put the pixel in, it's still faster then the 
-- column-major loops, though

how about using an 1D loop to set the pixels?

local LargeCanvas = require(script.LargeCanvas):New(1024,1024,Enum.ResamplerMode.Pixelated)
local size = 1024 * 1024 * 4
local screen = LargeCanvas.pixels[1]

LargeCanvas.gui.Parent = script.Parent

while true do
     -- start timing the loop here with os.clock()
	for i=1,size,4 do
		local r = math.random()
		local g = math.random()
		local b = math.random()
		screen[i] = r
		screen[i + 1] = g
		screen[i + 2] = b
		screen[i + 3] = 1
	end
    -- end timing the loop here ~> 0.19 second per iteration
	task.wait()
end
-- this takes around 0.19 seconds iteration to put the pixels in the screen buffer
-- in other words, 5.2fps

since the 1D loop is the fastest, let’s see if it’s faster in rust

use std::thread::sleep;
use std::time::{Duration,Instant};
use rand::{thread_rng, Rng};
const WIDTH : usize = 1024;
const HEIGHT : usize = 1024;
const SIZE : usize = WIDTH * HEIGHT * 4;
const REFRESH_RATE : Duration = Duration::from_millis(16); // roughly 60fps
fn main() {
    let mut random = thread_rng();
    let mut buffer: Vec<u8> = vec![0; SIZE]; // create pixel buffer here
   
    loop {
        let benchmark = Instant::now()  // start timing the loop here 
        for i in (0..SIZE).step_by(4){
            buffer[i] = random.gen_range(0..255);
            buffer[i+1] = random.gen_range(0..255);
            buffer[i+2] = random.gen_range(0..255);
            buffer[i+3] = random.gen_range(0..255);
        }
        println!("took {}", benchmark.elapsed().as_millis()); // 0.01 seconds, in other words, 100 fps
        sleep(REFRESH_RATE)
    }
}


this takes 0.01 seconds per iteration, or in other words, 100fps to put the pixels in the framebuffer

3 Likes

in Luau you should be able to get atleast 10 fps without needing native codegen, the problem is that math.random is slow to call compared to other functions. same thing applies with the Random object Roblox has.
if you for example calculate the UV coordinate by dividing the current x,y by the image size and use that for the RGB you can get about 30 fps with native code generation and 15 fps without.

in this example I use a window resolution of 1024x1024 and this for the colors:
R = UV.X
G = UV.Y
B = UV.X * UV.Y

With --!native

Without --!native

1 Like

I was able to get a 3D engine with some meshes of about 3000 vertices working in real-time, haven’t gotten to parallelize anything here and the code wasn’t really my proudest work but this is just for reference.

1 Like