OSGL - EditableImage graphics library

v1.1b, @Shadowball_X, @jukepilot

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, aims to provide a simple way for developers to read and write PixelData to EditableImages.

OSGL was originally developed as an alternative to CanvasDraw, a graphics-engine built for Roblox. Unlike CanvasDraw, OSGL attemtps to be a library solely for EditableImages alone.

OSGL is currently in some sort of “beta”, with some “expected” features not being implemented / still being worked on!

So, what can OSGL do?

OSGL provides the user with a “Window” (An EditableImage) that they can draw shapes, textures, text, etc onto. Features that OSGL provide are:

  • Shapes

  • Textures & sprites

  • Texts & custom fonts

  • Custom rendering

  • It even comes with its own error handler!

This library would be a great help to those who would like to make pixel-based 2D/3D games, or where storing Pixel Data is needed.

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. The Github provides “OSGL_Import”, which allows you to import Images (PNGs Specifically) from your local device.

Quick tutorial

You can learn more on the Docs.
The following tutorial will show you how to open a window and draw a basic circle to it.

Opening the window

Before we can start drawing on a window, we need to create one. Place the OSGL module somewhere, and require it. You can create a frame, or any GuiObject. That will be your canvas:

Now, we can create our window:

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

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

The code above creates a new window for OSGL to use. We import enum and window from the OSGL module, and use the window.new function. We pass 1 parameter, a dictionary with 2 keys: Size, and Instance. Size can be of type Vector2 or enum.WindowSize. In this case, we pass enum.Mutable, meaning the size will change based of the size of the frame we provide., and instance as the frame that our window will become. Finally, we call this as a function as we want to create the window - We can call the create window function, but the window will not be created until we call that function meaning windows can be stored and used at a later date.

Now, we can set-up our main loop. This will run every heartbeat:

while myWindow:isOpen() do
	
end

The “IsOpen” method yields for the given amount of time (or a heartbeat), and returns if the window exists or not.

Drawing to the window

Now, we need to import 2 other modules to help us draw the circle: shape, and color. Before we render, we always need to clear the screen otherwise the previous contents will appear. This can be done with the clear(RGBA) method:

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.Mutuable,
	Instance = script.Parent
})()

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

But if you run this, nothing will happen. That’s because we don’t render our changes to the screen yet. This can be done with the render method:

while myWindow:isOpen() do
	myWindow:clear(color.Black)
	
	--// Draw our circle here
	
	myWindow:render()
end

Anything that needs to be drawn to the screen in OSGL requires the draw method. We can then pass shape.circle which constructs a circle:

while myWindow:isOpen() do
	myWindow:clear(color.Black)
	
	--// Draw our circle here
	myWindow:draw(
		shape.circle({
			Color = color.Blue,
			Position = Vector2.new(50, 50),
			Radius = 25
		})
	)
	
	myWindow:render()
end

And depending on the size of your frame, a blue circle should have been rendered:
image

Full Code
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.Mutuable,
	Instance = script.Parent
})()

while myWindow:isOpen() do
	myWindow:clear(color.Black)
	
	--// Draw our circle here
	myWindow:draw(
		shape.circle({
			Color = color.Blue,
			Position = Vector2.new(50, 50),
			Radius = 25
		})
	)
	
	myWindow:render()
end

Contribution

OSGL is an open-source project. Feel free to edit source code or give ideas - You can do this in the github or here in the DevForum.

Credits

You are not required to credit me for OSGL, it is optional. While you may edit the source as you like, please do not reupload / claim this asset as your own work(s)!


I hope those who use this module make something fun with it - It was a blast making this for all of you! This is my first “big module post”, I hope you enjoyed reading!!

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

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

1 Like

yo @Ethanthegrand14 check this out!

1 Like

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

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

2 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
1 Like

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

1 Like

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:

1 Like

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)

1 Like

He never misses, The absolute legend :star_struck:

1 Like

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.

1 Like

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.

1 Like

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.

2 Likes

This looks very interesting. I will try this out!

1 Like

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

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

2 Likes