v2026.9.4
All Bundles
Bundle OpenGL 3.3 core rendering on top of SDL2. Builds on sdl2.obl for the window and context; this bundle is the GL side. Compile with -lib sdl2. Targets the **3.3 core profile**, forward-compatible. That is the highest common denominator across the platforms Objeck ships: Windows, Linux and macOS desktop all reach it, and macOS caps at 4.1 core so nothing above 4.1 is portable there. GLES-only hardware (Raspberry Pi and similar) is out of scope by construction. Layers, from thinnest to friendliest -- all public, so nothing is capped: * GLWindow -- a window and a 3.3 core context, opened and paced for you. * GL -- static state operations. Thin, but never per-vertex. * Shader -- a linked program; Flat/Textured/TexturedFog/Normals built in, so the common cases need no GLSL at all. * Mesh -- geometry uploaded once into buffer objects, drawn many times, with Cube/Plane/Quad/Sphere built in, OBJ loading, and instancing. * Texture2D -- a texture, from a file or generated; Solid/Checker built in. * Light -- one directional light plus ambient, written into a shader. * RenderTarget -- draw into a texture instead of the window, then sample it. * Material -- a surface: its texture, tint and shininess. * Frustum -- the six planes of a view, for skipping what is off screen. * ShadowMap -- shadows from a directional or spot light, depth pass and all. * PointShadow -- shadows from a point light, in every direction, via a cube map. * Overlay -- text and rectangles over the scene, positioned in pixels. * Transform -- position, rotation and scale, with a cached matrix. * Box, Scene -- a world of boxes: draws itself, and answers collision. ## Why the API is shaped this way The native call boundary is expensive: the VM resolves each native symbol by string on EVERY call (GetProcAddress/dlsym) and boxes every argument into a fresh holder. A 1:1 mapping of OpenGL onto that would be thousands of lookups and allocations per frame. So each call here does real work -- 'compile a program from two sources' is one call, not the five GL calls it decomposes into -- and bulk data crosses as whole arrays, never element by element. This happens to be exactly what GL 3.3 core wants anyway: upload geometry once, then draw with few calls. ## Adding a call One `void fn(VMContext&)` in the OpenGL section of core/lib/sdl/sdl.cpp, and one method here that names it. See that file's header for the two rules that matter (positional slot indices, and keeping each call coarse).

GLWindow

A window with a GL 3.3 core context, ready to draw into. This exists because opening one by hand takes about thirty lines that are the same in every program: initialise SDL, set six context attributes in the right order and before the window, create the window with the GL flag, create the context, check you actually got 3.3 core, resolve the entry points, and set the viewport from the DRAWABLE size rather than the window size. Across the four programs in this repo that block was 105 lines, and the only things that varied were the title, the size, one flag and the wording of an error message. ## What it decides for you A depth buffer is always requested and GL_DEPTH_TEST is enabled, because the two are separate calls sixty lines apart and forgetting the attribute makes the enable silently do nothing. BeginFrame clears colour and depth together for the same reason. Vsync is requested, and whether it was actually granted is remembered -- so EndFrame adds a delay only when it was not. Sleeping on top of a vsynced swap is a double wait, which is why frame pacing is worth owning rather than leaving to each program to hand-roll differently. ## What it does not take away Nothing is capped. GetEvent hands back the SDL Event if you would rather drive the queue yourself, Keyboard->GetState is unaffected by any of this, and every GL, Shader, Mesh and Texture2D call remains available exactly as before.

Example

window := GLWindow->New("Cube", 720, 540);
if(<>window->IsOk()) {
window->GetError()->ErrorLine();
return;
};

while(<>window->PollEvents()) {
window->BeginFrame();
# ... draw ...
window->EndFrame();
};

window->Free();

Operations

ApplyDeadzone # function

The dead-zone curve on its own, as a pure function. Split out from GetPadAxis so it can be TESTED. The rest of the controller path needs hardware to exercise and neither CI nor a build machine has any, so the one piece with actual logic in it is the one piece that must be reachable without a pad plugged in. It is public because a program reading a stick through some other route wants the same curve.

function : ApplyDeadzone(raw:Float, deadzone:Float) ~ Float

Parameters

NameTypeDescription
rawFloat-1..1, as GetPadAxisRaw returns
deadzoneFloatfraction of full travel to ignore

Return

TypeDescription
Float-1..1, rescaled so it ramps from 0 at the edge of the dead zone

BeginFrame #

Start a frame: measure elapsed time, then clear colour and depth. Depth is cleared as well as colour because a depth buffer is always requested and depth testing is always on -- clearing only colour leaves last frame's depth values in place, and the second frame then renders with everything mysteriously behind everything else.

method : public : BeginFrame() ~ Nil

ButtonBit #

Buttons as a bit, so held state is one Int rather than an array.

method : private : ButtonBit() ~ Int

Parameters

NameTypeDescription

ClosePad #

Let go of the controller and forget its state.

method : private : ClosePad() ~ Nil

EndFrame #

Present the frame. Read any pixels BEFORE calling this -- GL->ReadPixel reads the back buffer, and a swap is allowed to discard it. Sleeps only when vsync was refused. With vsync on, the swap already blocks until the display is ready and a second wait would halve the frame rate.

method : public : EndFrame() ~ Nil

Free #

Close everything, in the order that works. Relative mouse mode first while the window still exists, then the context, then the window, then SDL. GL objects you created -- Shader, Mesh, Texture2D -- are yours to free, and must be freed BEFORE this, while the context is still current. See the shutdown order on the GL class. Safe to call twice.

method : public : Free() ~ Nil

GetAspect #

method : public : GetAspect() ~ Float

Return

TypeDescription
Floatwidth divided by height, for Matrix4->Perspective

GetContext #

The GL context.

method : public : GetContext() ~ GLContext

Return

TypeDescription
GLContextthe context

GetDelta #

Seconds since the previous BeginFrame, clamped to 0.1. Multiply movement by this and the program behaves the same at 30 and at 240 frames a second. The first frame reports 0.0, so nothing jumps on startup.

method : public : GetDelta() ~ Float

Return

TypeDescription
Floatthe frame time in seconds

GetDrawableGeneration #

A number that changes whenever the drawable changes size. PollEvents keeps the viewport right on its own, but a projection matrix is the program's, and a stale one stretches the scene while a HUD drawn in pixels stays correct. Compare this against the last value you saw and rebuild what depends on the size when it moves (see the example). For the projection specifically there is nothing to compare -- call Camera->SetAspect(window->GetAspect()) every frame and it does nothing unless the value actually changed.

method : public : GetDrawableGeneration() ~ Int

Return

TypeDescription
Inta counter, not a size; only equality is meaningful

Example

if(window->GetDrawableGeneration() <> @seen) {
  @seen := window->GetDrawableGeneration();
  target->Free(); target := RenderTarget->New(window->GetWidth(), ...);
};

GetError #

Why opening failed.

method : public : GetError() ~ String

Return

TypeDescription
Stringthe reason, or an empty string when IsOk is true

GetEvent #

The event this window polls with. Here so a program with its own event needs can drive the queue directly instead of calling PollEvents -- do one or the other, not both, since whichever drains the queue first is the only one that sees each event.

method : public : GetEvent() ~ Event

Return

TypeDescription
Eventthe event

GetFps #

Frames per second as actually measured, not the target.

method : public : GetFps() ~ Float

Return

TypeDescription
Floatthe rate, or 0.0 before the second frame

GetHeight #

The drawable height in pixels.

method : public : GetHeight() ~ Int

Return

TypeDescription
Intthe height in pixels

GetMouseDeltaX #

Mouse movement accumulated by the last PollEvents, in pixels.

method : public : GetMouseDeltaX() ~ Float

Return

TypeDescription
Floathorizontal movement this frame

GetMouseDeltaY #

method : public : GetMouseDeltaY() ~ Float

Return

TypeDescription
Floatvertical movement this frame, positive downward as SDL reports it

GetMouseX #

Where the mouse was, in window coordinates. POINTS, not pixels -- SDL reports event coordinates in the window's coordinate space, which on a high-DPI display is not the drawable size GetWidth returns. Multiply by GetScale if you need pixels.

method : public : GetMouseX() ~ Int

Return

TypeDescription
Intdistance from the left edge

GetMouseY #

method : public : GetMouseY() ~ Int

Return

TypeDescription
Intdistance from the top edge, in points

GetOwnedCount #

method : public : GetOwnedCount() ~ Int

Return

TypeDescription
Inthow many objects this window will release on the way out

GetPad #

The controller itself, for everything this wrapper does not cover -- rumble, the LED, touchpads, the gyro. Do NOT close it; the window owns it and closes it on Free.

method : public : GetPad() ~ GameController

Return

TypeDescription
GameControllerthe controller, or Nil

GetPadAxis #

A controller axis as -1..1, with the dead zone taken out. Raw SDL axes are -32768..32767 and a stick at rest does not read zero: it wanders by a few hundred, and a camera driven straight off the raw value drifts on its own. Every program that uses a stick has to solve this, so it is solved once here. The dead zone is RESCALED rather than clamped. Clamping gives a stick that does nothing and then jumps to 0.15; rescaling ramps from 0 at the edge of the dead zone to 1 at full deflection, so slow movement stays possible. That difference is the whole reason to do it here rather than leave callers an `if` to write. Triggers are axes too, and they rest at -1 rather than 0 -- so read a trigger with GetPadAxisRaw if a resting value of -1 matters to you.

method : public : GetPadAxis(axis:GameControllerAxis) ~ Float

Parameters

NameTypeDescription
axisGameControllerAxisa GameControllerAxis

Return

TypeDescription
Float-1..1, or 0.0 when there is no pad

GetPadAxisRaw #

A controller axis as -1..1 with NO dead zone applied.

method : public : GetPadAxisRaw(axis:GameControllerAxis) ~ Float

Parameters

NameTypeDescription
axisGameControllerAxisa GameControllerAxis

Return

TypeDescription
Float-1..1, or 0.0 when there is no pad

GetPadDeadzone #

method : public : GetPadDeadzone() ~ Float

Return

TypeDescription
Floatthe dead zone as a fraction of full travel

GetPadName #

The controller's name, e.g. "Xbox Series Controller".

method : public : GetPadName() ~ String

Return

TypeDescription
Stringthe name, or an empty string when there is no pad

GetSamples #

A second GLWindow is not really supported, and this is why. Creating a context MAKES IT CURRENT, so opening a second window silently redirects every later GL call to it -- textures, draws and pixel readback alike, with no error anywhere. And Free calls Core->Quit, which takes SDL down for the whole process, so freeing either one kills both. If you must open a second window, put the first one's context back afterwards: first->GetWindow()->GLMakeCurrent(first->GetContext()); and free only at exit. Both facts were found by a test that opened a second window to compare pixel formats: it hung on the first Free, and once that was fixed, three unrelated checks elsewhere in the suite went red because they were sampling the wrong framebuffer. How many multisample samples this window actually got. Asked for is not granted: a driver that cannot honour the request makes the window fail to create, and rather than not opening at all this drops the request and retries without it. So a program that cares has to ask, not assume.

method : public : GetSamples() ~ Int

Return

TypeDescription
Intthe sample count, or 0 for no multisampling

GetVersion #

The driver's version string for this context, e.g. "3.3.0 Core Profile Context 26.7.1". Read while opening, so this costs nothing to ask for.

method : public : GetVersion() ~ String

Return

TypeDescription
Stringthe version string, or empty when the context never opened

GetWheelX #

Horizontal wheel movement accumulated by the last PollEvents.

method : public : GetWheelX() ~ Int

Return

TypeDescription
Intnotches, positive to the right

GetWheelY #

Wheel movement accumulated by the last PollEvents.

method : public : GetWheelY() ~ Int

Return

TypeDescription
Intnotches, positive away from the user

GetWidth #

The drawable width in PIXELS -- what GL->Viewport and GL->ReadPixel work in. On a high-DPI display this is LARGER than the width that was asked for, typically double, because the window is requested in points and GL works in pixels. That is not hypothetical: high-DPI is requested, so on a Retina Mac a 960-point window reports 1920 here.

method : public : GetWidth() ~ Int

Return

TypeDescription
Intthe width in pixels

GetWindow #

The SDL window, for anything this class does not wrap.

method : public : GetWindow() ~ Window

Return

TypeDescription
Windowthe window

HasAudio #

Did the audio device open? False when audio was not asked for, and also when it was asked for and the machine has no working output -- which is common on a CI runner and is not a reason to refuse to run. Sound is the thing to make optional here, not the program.

method : public : HasAudio() ~ Bool

Return

TypeDescription
Booltrue when the mixer is usable

HasPad #

Is a controller attached? False when none is plugged in AND when the controller subsystem could not start at all -- a program should treat both the same way, since in either case there is nothing to read.

method : public : HasPad() ~ Bool

Return

TypeDescription
Booltrue when GetPad returns something usable

HasVsync #

method : public : HasVsync() ~ Bool

Return

TypeDescription
Boolwhether the swap interval was actually granted

IsOk #

method : public : IsOk() ~ Bool

Return

TypeDescription
Booltrue when the window and context are usable

KeyHeld #

Is this key held down right now? A STATE, not an edge -- true for as long as the key is down, which is what movement wants. WasPressed is the edge, true for one frame per press. The bounds check is the point. Reading Keyboard->GetState directly means writing this every time: if(keys <> Nil & scancode >= 0 & scancode < keys->Size()) { ... } and both examples in this repo that take keyboard input had written it, byte for byte identically, because held MOUSE was framework state and held keys were not. Reads a snapshot taken once per PollEvents, so asking about eight keys costs eight array reads rather than eight native calls.

method : public : KeyHeld(scancode:Int) ~ Bool

Parameters

NameTypeDescription
scancodeInta Scancode value

Return

TypeDescription
Booltrue while the key is down; false before the first PollEvents

KeyHeld #

Is either of these keys held? For the case that actually recurs: one action on two keys -- left shift or right shift, W or Up. An array would have read better, but an Objeck array literal takes literal expressions only, so `[Scancode->SDL_SCANCODE_W, ...]` does not compile and the caller would have to build and fill an Int[] to ask a one-line question.

method : public : KeyHeld(first:Int, second:Int) ~ Bool

Parameters

NameTypeDescription
firstInta Scancode value
secondIntanother

Return

TypeDescription
Booltrue if either is down

MouseHeld #

Is this mouse button down now? A state rather than an edge, tracked across frames from the button events -- so unlike the key case there is no separate query to reach for.

method : public : MouseHeld(button:Int) ~ Bool

Parameters

NameTypeDescription
buttonInt1 left, 2 middle, 3 right

Return

TypeDescription
Booltrue while it is held

MousePressed #

Did this mouse button go down during the last PollEvents?

method : public : MousePressed(button:Int) ~ Bool

Parameters

NameTypeDescription
buttonInt1 left, 2 middle, 3 right

Return

TypeDescription
Booltrue if it was pressed this frame

MouseReleased #

Did this mouse button come up during the last PollEvents?

method : public : MouseReleased(button:Int) ~ Bool

Parameters

NameTypeDescription
buttonInt1 left, 2 middle, 3 right

Return

TypeDescription
Booltrue if it was released this frame

New # constructor

Open a visible window and a GL 3.3 core context.

New(title:String, width:Int, height:Int)

Parameters

NameTypeDescription
titleStringthe window title
widthIntwidth in points
heightIntheight in points

New # constructor

Open a window with multisampling. Antialiasing at the framebuffer, which costs nothing in the program: it is two pixel-format attributes, and every edge in the scene stops being a staircase. It has to be asked for BEFORE the window exists, which is why it is a constructor argument rather than a setter -- SDL attributes select a pixel format and setting one afterwards is silently ignored. Ask for 4. If the driver cannot provide it the window still opens, without it -- so check GetSamples if you care, rather than assuming.

New(title:String, width:Int, height:Int, samples:Int)

Parameters

NameTypeDescription
titleStringthe window title
widthIntwidth in points
heightIntheight in points
samplesInt0 for none, or 2, 4, 8; anything else is clamped

New # constructor

Open a window with sound. Game.OpenGL has no audio of its own and does not need any: Game.SDL2's Mixer, MixChunk and MixMusic need no SDL_Renderer, so a 3D program can use them directly. What was missing was the initialisation -- nothing opened the audio device, and nothing said so, which left a working mixer looking like an absent feature. This asks for the audio subsystem alongside video and opens the device. After it, Game.SDL2's mixer is usable as-is: window := GLWindow->New("game", 1024, 640, 4, true); chime := MixChunk->New("pickup.wav"); chime->PlayChannel(-1, 0); Free the chunks before the window: GLWindow->Free calls Core->Quit, which takes the audio device with it.

New(title:String, width:Int, height:Int, samples:Int, audio:Bool)

Parameters

NameTypeDescription
titleStringthe window title
widthIntwidth in points
heightIntheight in points
samplesIntmultisample count, 0 for none
audioBooltrue to open the audio device

New # constructor

Open a window that may be hidden. A hidden window still has a real context and still renders, so this is how a test or a headless tool draws and reads pixels back without a window being flashed at anyone.

New(title:String, width:Int, height:Int, visible:Bool)

Parameters

NameTypeDescription
titleStringthe window title
widthIntwidth in points
heightIntheight in points
visibleBoolfalse to keep the window off screen

OpenPad #

Adopt the first attached controller, if there is one and we have none. Cheap when nothing is plugged in -- one call for the count and then nothing. Called at startup and again whenever SDL reports a device arriving, rather than every frame.

method : private : OpenPad() ~ Nil

Own #

Hand something to the window to release on the way out. Every example in this repo hand-writes the same teardown: a chain of `if(x <> Nil) { x->Free(); }` in a fixed order, up to fifteen objects long, ending with the window. It is mechanical, it is easy to get one line wrong, and the one rule that matters -- everything GL before the context goes -- is invisible in it. Own returns what it was given, so it wraps a constructor (see the example) and Close() disappears. There is no ordering to get right WITHIN the owned set: glDelete* is order-independent and nothing here references another object, so one list is enough. What matters is that all of it happens before the context is destroyed, which is what this guarantees. Owning something does NOT stop you freeing it yourself -- every Free in this bundle nulls its handle and is safe to call twice, so Own can be adopted a line at a time. Do not Own a borrowed texture from RenderTarget->GetTexture or ShadowMap->GetTexture: those are views of an attachment their target still owns. Freeing one is already a no-op, so this is a waste rather than a hazard, but it says nothing true about ownership.

method : public : Own(thing:Freeable) ~ Freeable

Parameters

NameTypeDescription
thingFreeableanything with a Free

Return

TypeDescription
Freeablethe same thing, so this can wrap a constructor

Example

@shader := window->Own(Shader->LitTextured())->As(Shader);
@cube   := window->Own(Mesh->Cube())->As(Mesh);

PadHeld #

Is a controller button down right now?

method : public : PadHeld(button:GameControllerButton) ~ Bool

Parameters

NameTypeDescription
buttonGameControllerButtona GameControllerButton

Return

TypeDescription
Booltrue while it is held

PadPressed #

Did a controller button go down during the last PollEvents? The edge, not the level -- a menu that advances once per press wants this, and PadHeld would advance it every frame the button was down.

method : public : PadPressed(button:GameControllerButton) ~ Bool

Parameters

NameTypeDescription
buttonGameControllerButtona GameControllerButton

Return

TypeDescription
Booltrue on the frame it was pressed

PadReleased #

Did a controller button come up during the last PollEvents?

method : public : PadReleased(button:GameControllerButton) ~ Bool

Parameters

NameTypeDescription
buttonGameControllerButtona GameControllerButton

Return

TypeDescription
Booltrue on the frame it was released

PollPad #

Read the buttons and work out this frame's edges. Polled rather than event-driven, which is the same choice the keyboard makes: Event exposes GetJButton for raw joysticks but has no accessor for SDL's CONTROLLER button events, so the payload is unreachable from here. The event TYPE is reachable, which is enough to notice a device arriving or leaving -- so hot-plug is event-driven and state is polled. Costs nothing at all when no pad is attached, which is the common case.

method : private : PollPad() ~ Nil

RequestQuit #

Ask PollEvents to report a quit on the next call, e.g. from a menu.

method : public : RequestQuit() ~ Nil

SetDepthTest #

Turn depth testing on or off. On by default. On is the right default for 3D -- without it, whatever was drawn last is in front, regardless of where it is -- and the depth buffer is always requested, so enabling it can never silently do nothing the way it can when the SDL_GL_DEPTH_SIZE attribute was forgotten. The cost of that default: a frame cleared with GL_COLOR_BUFFER_BIT alone leaves stale depth values, and geometry then fails the test for no visible reason. BeginFrame clears both. Turn this off for a purely 2D pass rather than clearing colour alone.

method : public : SetDepthTest(enabled:Bool) ~ Nil

Parameters

NameTypeDescription
enabledBoolfalse to draw in submission order

SetPadDeadzone #

How far a stick must move before it counts, as a fraction of full travel. 0.15 by default. Raise it for a worn pad that drifts; 0 turns it off and makes GetPadAxis the same as GetPadAxisRaw.

method : public : SetPadDeadzone(fraction:Float) ~ Nil

Parameters

NameTypeDescription
fractionFloat0.0 to 1.0; values outside are clamped

SetQuitOnEscape #

Whether Escape quits. On by default, which is what every example wants. Turn it off for a program where Escape should close a menu instead -- until now that meant abandoning PollEvents entirely and re-implementing quit, resize and mouse delta by hand, because Escape-quits was not optional.

method : public : SetQuitOnEscape(on:Bool) ~ Nil

Parameters

NameTypeDescription
onBoolfalse to leave Escape to the program

SetTargetFps #

The frame rate EndFrame paces to when there is no vsync. Ignored while vsync is on, since the swap does the waiting.

method : public : SetTargetFps(fps:Int) ~ Nil

Parameters

NameTypeDescription
fpsIntframes per second; values below 1 are ignored

WasPressed #

Did this key go down during the last PollEvents? An EDGE, not a state: true for exactly one frame per physical press, and auto-repeat does not count. For "is it held right now", read Keyboard->GetState, which this does not disturb.

method : public : WasPressed(scancode:Int) ~ Bool

Parameters

NameTypeDescription
scancodeInta Scancode value

Return

TypeDescription
Booltrue if it was pressed this frame

WasReleased #

Did this key come up during the last PollEvents?

method : public : WasReleased(scancode:Int) ~ Bool

Parameters

NameTypeDescription
scancodeInta Scancode value

Return

TypeDescription
Booltrue if it was released this frame