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 fast and lightweight graphical library that makes it easy to render pixels onto EditableImage objects. With support for drawing shapes, loading textures, and manipulating pixel data, OSGL was built from the ground-up for performance and simplicity.

Originally inspired by CanvasDraw, OSGL was created specifically for Roblox developers who need a reliable tool for pixel-based rendering. It focuses on doing one thing well: giving developers an efficient way to create graphics using the in-built EditableImage.

Currently in beta, OSGL is already the fastest public EditableImage library available. 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!

So, what can OSGL do?


In OSGL, a “Window” is just another name for an EditableImage; a canvas where you can draw anything. You can create shapes, color individual pixels, or load textures directly from your PC in multiple formats to use in-game.

OSGL also includes built-in error handling to make your life easier, catching common EditableImage issues, like the dreaded “buffer out of bounds” error! Scary!

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


This guide explains how to draw a circle using OSGL.

Note: This tutorial assumes a basic understanding of Luau. If you are new to the language, refer to available learning resources.

For additional information, visit the OSGL Documentation.


Setting Up

  1. Download OSGL
    Obtain the latest version of OSGL from either:

    Insert the downloaded module into your project, such as ReplicatedStorage/Packages.

  2. Create an ImageLabel
    Add an ImageLabel to StarterGui. Set the BackgroundTransparency property to 0. This ImageLabel will serve as your canvas for rendering.

    :warning: Tip: If you encounter blurred images at low resolutions, or if you want a pixelated look, set the ResampleMode property of the ImageLabel to Pixelated.

  3. Add a LocalScript
    Create a LocalScript in an appropriate location (e.g., StarterPlayer/StarterPlayerScripts). This script will manage the OSGL window and rendering.


Creating a Window

An OSGL window is essentially a wrapper for an EditableImage, providing a simplified API for rendering. To create a window:

  1. Import OSGL Modules
Copy code
local OSGL = require(ReplicatedStorage.Packages.OSGL)
local Window = OSGL.Window
  1. Choose a Window creation method
    OSGL provides several functions to create a window:
  • 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 are starting fresh, we’ll use Window.new:

Copy code
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 })

At this point, the window is ready for rendering.

Rendering to the Window


The following example sets the target frame rate, clears the screen to black, and renders the updated buffer:

Copy 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)
    :Render()
  • Clear(color): Clears the window with the specified color.
  • Render(): Displays the updated content.

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:

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

The draw sub-module can be utilized to render directly onto the buffer. All drawing functions require a Window or Texture (known as a DrawableObject) to specify where to draw. As an example, a pixel could be drawn by using the appropriate draw function provided by OSGL:

Copy code
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:

Copy code
myWindow
    :Clear(color.BLACK)
    :Draw() -- Open a `DrawingContext`
    :Pixel(0, 0, color.RED) -- `myWindow` is automatically passed as the first argument
    :StopDrawing()
    :Render()
  • Draw(): Opens a drawing context, allowing you to use drawing functions.
  • Pixel(x, y, color): Draws a pixel at a location with the given color.
  • StopDrawing(): Ends the drawing context and returns the Window object.

The draw.circle function, or the Circle method can be used to draw a circle onto the Window:

Copy code
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:

Congratulations! You’ve rendered your first circle using OSGL. For more details on other features and functions, refer to the OSGL Documentation.

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.


54 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!

5 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