OSGL - EditableImage graphics library

v1.4b, @saaawdust, @jukepilot, @msix29, @Sle_l

Links

DocsRobloxGithubDiscord


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

About

What is OSGL?

OSGL (Open-Source Graphical Library) is a graphical library designed to offer developers a straightforward, fast, and efficient way to read and write pixels 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.

Currently in beta, OSGL is continually evolving, with many features coming soon. It’s user-friendly and specifically designed to be as fast as possible. Whether you’re building pixel art, animations, or unique visual effects, OSGL will allow you to get what you need, done. If you’re among the users who can use OSGL in live games, we’d love to see what you create!

OSGL is currently the most performant public EditableImage library on the platform.

So, what can OSGL do?

In OSGL, a “Window” is essentially an EditableImage that allows you to draw whatever you want. You can easily create shapes, draw pixels, or even load textures directly from your PC in various file formats. This flexibility gives you the freedom to do absolutely anything! OSGL also comes with its own error handling, sometimes catching and managing common (and annoying) EditableImage errors! (Buffer out of bounds, I’m talking to you.)

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:

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, v1.3b v1.4b currently have no examples.

OSGL v1.1b Examples

144P Shrek, sampled at 15FPS:

Minecraft Wireframe 3D Renderer (60FPS):

image image image

Live shading and rotating render:
image

Quick tutorial

:warning: This documentation assumes a basic understanding of Luau. For those new to the language, numerous resources are available for learning.

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

Setting up

If you haven’t done so already, download the latest version of OSGL from either the Github releases or from the Roblox Marketplace. Once downloaded, insert it into Studio in a suitible place (such as ReplicatedStorage/Packages)

Create an ImageLabel in StarterGui, with its BackgroundTransparency set to 0. This ImageLabel will act as your primary canvas for rendering graphics.

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

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

Creating a window

In OSGL, a “window” is actually just a fancy name for an EditableImage. All that OSGL does, is wrap this EditableImage in an easy-to-use API for you; with a lot of handy features.

Before we can actually draw on our window, we need to create one. OSGL is split into sub-modules that each serve a different purpose (e.g, drawing on a window, creating a window, etc.). In this case, we want the Window class which allows us to create our window.

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

There are four functions available for creating our window: Window.from, Window.new, Window.fromAssetId, and Window.fromBuffer . 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.

Window.fromAssetId: Given an assetId, creates a Window.

Window.fromBuffer: Given a buffer, creates a Window.

Since we don’t have an existing EditableImage and do not wish to use an assetId, nor a buffer, we’ll use Window.new to create our window directly on the designated ImageLabel:


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

local windowUi = -- *reference to windowUi, our `ImageLabel`*

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

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

And that’s it! We have our OSGL window ready and setup for rendering!

Rendering to our window

Now that we have our window, let’s make this code a bit more interesting:

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

local windowUi -- reference to windowUi, our `ImageLabel`

-- Create our window, 500x500
local myWindow = Window.new(windowUi, { sizeX = 500, sizeY = 500 })
myWindow.targetFPS = 270
myWindow
    :Clear(color.BLACK)
    :Render()

In summary, the code above creates a window with a size of 500x500 pixels, setting the target FPS to 270. It then clears the screen using the BLACK color (0, 0, 0, 255). The Clear method clears the window’s buffer with the specified color, and the Render method displays the updated buffer on the screen.

It’s important to note that the method calls do not need to be chained. You can achieve the same effect with separate statements, as shown below:

myWindow:Clear(color.BLACK)
myWindow:Render()

It is at this point where we can render our circle.
We can utilize the draw sub-module to render directly onto the buffer. All drawing functions require a Window or Texture (known as a DrawableObject) to specify where to draw. For example, to draw a pixel, you would use the appropriate draw function provided by OSGL:

myWindow:Clear(color.BLACK)
-- It isn't necessary to clear the screen. If you want to keep the contents
-- of the previous frame, you can!

-- Draw a red pixel on `myWindow`, at 0, 0
draw.pixel(myWindow, 0, 0, color.RED)

myWindow:Render()

In this example, each operation within the loop is presented as separate statements. However, these statements can be combined into a single statement using method chaining, as shown below:

myWindow
    :Clear(color.BLACK)
    :Draw() -- Open a `DrawingContext`
    :Pixel(0, 0, color.RED) -- `myWindow` is automatically passed as the first argument
    :StopDrawing()
    :Render()

Almost all methods in Window return the Window itself, however if you want to draw in the same statement, you can use the Draw method, which will allow you to use all of the drawing functions. When you want to get the Window object again, you can call StopDrawing and the Window object will be passed back.

We want to create a circle, so we can use the draw.circle function to make our circle:

myWindow
    :Clear(color.BLACK)
    :Draw()
    :Circle(250, 250, 50, color.RED)
    :StopDrawing()
    :Render()

This code will draw a red circle at (250, 250), with a radius of 50:
image

Perfect! You have rendered your first circle with OSGL! The docs contain all the information you need to know, so It’s recommended that you go read them!

Full code

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

local windowUi – reference to windowUi, our ImageLabel

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

myWindow
:Clear(color.BLACK)
:Draw()
:Circle(250, 250, 50, color.RED)
:StopDrawing()
:Render()

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, Discord, or even 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.


46 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.

7 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?

4 Likes

yo @Ethanthegrand14 check this out!

4 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

9 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.

5 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

3 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:

3 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