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

Scene

A world of boxes that draws itself and answers collision queries. This is the layer that keeps a 3D program from hand-rolling parallel arrays and index arithmetic. Add boxes, then call Draw once per frame and Blocked whenever something wants to move. ## Two things it does that a naive loop would not It only rebinds a texture when the next box uses a different one, so a scene grouped by texture costs a handful of binds rather than one per box. It draws through Matrix4->MultiplyInto with a scratch buffer it owns, so a frame allocates nothing. The obvious version -- Multiply per box per frame -- returns a fresh Float[16] each time, which is hundreds of arrays a second and enough to collect mid-frame. Objeck's SDL renderer already had to be rewritten once for exactly this reason.

Example

scene := Scene->New(cube_mesh, shader);
scene->AddBox(Vector3->New(0.0, 1.0, -4.0), Vector3->New(1.0, 1.0, 1.0), crate);
...
scene->Draw(view_projection);
if(<>scene->Blocked(new_x, new_z, 0.35)) { ... };

Operations

Add #

Add an existing box.

method : public : Add(box:Box) ~ Box

Parameters

NameTypeDescription
boxBoxthe box

Return

TypeDescription
Boxthe same box, for chaining

AddBatch #

Add an instanced batch, drawn in one call after the solid geometry. A batch brings its own shader, so it is not affected by SetShader and does not take part in the material runs the boxes and props share. It inherits the scene's light when it has none of its own. Note it counts as ONE toward GetDrawn, not one per instance -- what that number measures is submissions, and the whole point of a batch is that four hundred trees are a single submission. ## Batches do not cast shadows DrawInto walks the boxes and the props, not the batches. The depth pass runs one shader over everything, and DepthOnly is a per-object program -- an instanced depth shader would be a second one, and Scene would have to switch between them mid-pass. So a batch is lit but casts nothing, which is fine for grass and wrong for pillars. Use props for anything whose shadow matters.

method : public : AddBatch(batch:PropBatch) ~ PropBatch

Parameters

NameTypeDescription
batchPropBatchthe batch; NOT owned by the scene, so Clear does not free it

Return

TypeDescription
PropBatchthe same batch, for chaining

AddBox #

Build and add a box.

method : public : AddBox(center:Vector3, half:Vector3, texture:Texture2D) ~ Box

Parameters

NameTypeDescription
centerVector3the box's centre
halfVector3half-extents on each axis
textureTexture2Dthe texture to draw it with

Return

TypeDescription
Boxthe new box, so it can be made non-solid or moved later

AddProp #

Add a prop -- a mesh, material and transform of its own. Returned so it can be kept and moved: lamp := scene->AddProp(Prop->New(lamp_mesh, brass)); lamp->GetTransform()->SetPosition(2.0, 0.0, -3.0); Props are drawn after the boxes in the solid pass, and join the boxes in the sorted pass if they are transparent. They do NOT block movement -- Scene->Blocked walks only the boxes, because a plan-view overlap test says nothing about a rotated model.

method : public : AddProp(prop:Prop) ~ Prop

Parameters

NameTypeDescription
propPropthe prop to add

Return

TypeDescription
Propthe same prop

Blocked #

Whether a circle in plan view hits any SOLID box.

method : public : Blocked(x:Float, z:Float, radius:Float) ~ Bool

Parameters

NameTypeDescription
xFloatcircle centre x
zFloatcircle centre z
radiusFloatcircle radius

Return

TypeDescription
Booltrue when the position is blocked

Clear #

Drop every box and every prop. Does NOT free the shared mesh, shader or textures -- those were passed in and belong to the caller.

method : public : Clear() ~ Nil

Draw #

Draw every box. Binds the shader, then for each box sets the "mvp" uniform and draws the shared mesh. Rebinds a texture only when it changes from the previous box.

method : public : Draw(view_projection:Float[]) ~ Nil

Parameters

NameTypeDescription
view_projectionFloatthe combined view and projection matrix for this frame

DrawBatches #

Draw the instanced batches, then put back what they disturbed. A batch binds its own shader -- it has to, since an instanced program takes the camera as "view_projection" and reads the model matrix from a vertex attribute, which is a different shape from the per-object "mvp" every other shader here uses. Rebinding the scene's shader here is DEFENSIVE, not load-bearing, and it is worth being exact about that. Every uniform setter binds its own program before writing -- that was the phase-3 fix for uniforms landing on the wrong shader -- so the transparent pass would bind the right program anyway on its first SetMatrix4. This is here so the bound program matches what the scene believes for any future draw path that does not happen to set a uniform first. Removing it breaks no current test, which is exactly why the comment should not claim otherwise. What DID leak was the texture unit, which is global rather than per-program: a batch binds its material's texture, and anything drawn afterwards whose material carries no texture of its own sampled it. That is fixed in DrawOne rather than here, because the real fault was a coloured box never binding its own texture at all.

method : private : DrawBatches() ~ Nil

Parameters

NameTypeDescription

DrawInto #

Draw every box through a DIFFERENT shader, with a different matrix. For a pass that is not the normal one: a shadow map's depth pass wants this scene's geometry rendered from the light with a depth-only program, and nothing else about the scene changes. Deliberately minimal -- no light, no sampler, no per-object model matrix unless the shader asks for one -- because an auxiliary pass generally wants positions and nothing else, and setting uniforms a depth shader does not declare would file a diagnostic per box per frame.

method : public : DrawInto(shader:Shader, view_projection:Float[]) ~ Nil

Parameters

NameTypeDescription
shaderShaderthe program to draw with
view_projectionFloatthe matrix to combine with each box's model matrix

DrawOne #

Bind what one box needs and draw it. Split out because the two passes are the same work in a different order, and a second copy of it would be a second place for the material-run tracking to go wrong.

method : private : DrawOne() ~ Nil

Parameters

NameTypeDescription

EyeDistance #

Squared distance from the eye to a box's centre -- squared because only the ORDER matters, and a square root per box per frame buys nothing.

method : private : EyeDistance() ~ Float

Parameters

NameTypeDescription

Get #

method : public : Get(index:Int) ~ Box

Parameters

NameTypeDescription
indexIntwhich box

Return

TypeDescription
Boxthe box, or Nil when out of range

GetBatch #

method : public : GetBatch(index:Int) ~ PropBatch

Parameters

NameTypeDescription
indexInt0 to GetBatchCount() - 1

Return

TypeDescription
PropBatchthe batch, or Nil if the index is out of range

GetBatchCount #

method : public : GetBatchCount() ~ Int

Return

TypeDescription
Inthow many batches the scene holds

GetCount #

method : public : GetCount() ~ Int

Return

TypeDescription
Inthow many boxes are in the scene

GetCulledCount #

How many boxes the last Draw skipped as off-screen.

method : public : GetCulledCount() ~ Int

Return

TypeDescription
Intthe count, always 0 when culling is off

GetDrawnCount #

How many boxes the last Draw actually submitted. The last Draw only. DrawInto -- which is what a shadow pass runs, six times over for a point light -- deliberately leaves these alone, so a HUD reading them reports what the camera drew rather than whatever the final shadow face happened to submit.

method : public : GetDrawnCount() ~ Int

Return

TypeDescription
Intthe count

GetLight #

method : public : GetLight() ~ Light

Return

TypeDescription
Lightthe light, or Nil

GetLightRig #

method : public : GetLightRig() ~ LightRig

Return

TypeDescription
LightRigthe rig, or Nil when none was set

GetProp #

method : public : GetProp(index:Int) ~ Prop

Parameters

NameTypeDescription
indexInt0 to GetPropCount() - 1

Return

TypeDescription
Propthe prop, or Nil if the index is out of range

GetPropCount #

method : public : GetPropCount() ~ Int

Return

TypeDescription
Inthow many props this scene holds

IsCulling #

method : public : IsCulling() ~ Bool

Return

TypeDescription
Boolwhether off-screen boxes are being skipped

New # constructor

New(mesh:Mesh, shader:Shader)

Parameters

NameTypeDescription
meshMeshthe cube mesh every box is drawn with; it must span -1..1 on each axis, or geometry and collision will silently disagree (see Box)
shaderShaderthe program to draw with; Draw sets its "mvp" mat4 uniform

PropEyeDistance #

Squared distance from the eye to a prop, for the transparent sort.

method : private : PropEyeDistance() ~ Float

Parameters

NameTypeDescription

Raycast #

Fire a ray into the scene and report the nearest thing it hits. This is what a hitscan weapon, a "what am I looking at" prompt, a line-of-sight check or a mouse pick is built on -- none of which had any route before, since the only spatial query here was Blocked, which answers a plan-view movement question and nothing else. hit := scene->RaycastFrom(camera, 100.0); if(hit->IsHit()) { target := hit->GetProp(); ... }; ## What it tests against Boxes are tested against their actual axis-aligned extent, so the hit point and the normal are exact. Props are tested against their BOUNDING SPHERE, not their mesh. A prop is an arbitrary rotated model and this bundle never keeps its triangles after upload -- they live on the GPU -- so there is nothing here to intersect. The sphere is conservative, which means a shot can register slightly wide of a thin model. Put a Box where precision matters, the same way collision already works. Batches are NOT tested. A batch exists so that four hundred instances cost one draw call, and walking its transforms per shot would work but RayHit has nowhere to report WHICH instance was struck. Stating that rather than quietly returning a miss. Solidity is ignored on purpose: Box->IsSolid means "blocks movement", which is a different question from "stops a bullet". Filter on hit->GetBox()->IsSolid() if you want them to agree. Invisible props ARE skipped, since something you cannot see should not be shootable.

method : public : Raycast(origin:Vector3, direction:Vector3, max_distance:Float) ~ RayHit

Parameters

NameTypeDescription
originVector3where the ray starts
directionVector3which way it points; normalised here, so it need not be
max_distanceFloathow far to look; a hit beyond this is a miss

Return

TypeDescription
RayHita RayHit, never Nil -- ask IsHit

RaycastFrom #

Fire a ray from where the camera is, along where it is looking. The overwhelmingly common case -- a crosshair in the middle of the screen is exactly this ray.

method : public : RaycastFrom(camera:Camera, max_distance:Float) ~ RayHit

Parameters

NameTypeDescription
cameraCamerathe eye
max_distanceFloathow far to look

Return

TypeDescription
RayHita RayHit, never Nil

RaycastScreen #

Fire a ray through a point on the screen -- a mouse pick. The camera supplies the direction through that pixel and the scene answers what is along it. Pass the window so the ray is built from the same size the mouse coordinates were measured in. hit := scene->RaycastScreen(camera, window, window->GetMouseX(), window->GetMouseY(), 100.0);

method : public : RaycastScreen(camera:Camera, window:GLWindow, x:Int, y:Int, max_distance:Float) ~ RayHit

Parameters

NameTypeDescription
cameraCamerathe eye
windowGLWindowthe window the mouse coordinates came from
xIntpixel across
yIntpixel down
max_distanceFloathow far to look

Return

TypeDescription
RayHita RayHit, never Nil

RemoveBatch #

Take one batch out of the scene. Here so that the three lists behave the same way. Clear drops all three; leaving two of them removable individually and the third not would be the sort of asymmetry nobody remembers.

method : public : RemoveBatch(batch:PropBatch) ~ Bool

Parameters

NameTypeDescription
batchPropBatchthe batch to drop; NOT freed

Return

TypeDescription
Booltrue when it was in the scene

RemoveBox #

Take one box out of the scene. Note this changes the index of every box after it, so a loop holding indices across a removal is holding the wrong ones.

method : public : RemoveBox(box:Box) ~ Bool

Parameters

NameTypeDescription
boxBoxthe box to drop

Return

TypeDescription
Booltrue when it was in the scene

RemoveProp #

Take one prop out of the scene. Clear emptied everything and there was no way to remove a single thing, so anything that spawns and despawns -- an enemy, a pickup, a projectile -- had to rebuild the whole scene or hide the prop with SetVisible and leak it forever. Both are what this replaces.

method : public : RemoveProp(prop:Prop) ~ Bool

Parameters

NameTypeDescription
propPropthe prop to drop; it is NOT freed, since its mesh and material are usually shared

Return

TypeDescription
Booltrue when it was in the scene

ReserveFar #

Make room for count transparent boxes, growing only when it is not enough.

method : private : ReserveFar() ~ Nil

Parameters

NameTypeDescription

ResetMaterialState #

Undo whatever the last Material wrote, for geometry that has none. Only opacity was being reset, and Material->ApplyTo writes FOUR uniforms: tint, shininess, specular_strength and opacity. So one material'd object tinted every untextured object drawn after it -- in gl_lantern that meant the entire dungeon turned the relics' gold from the second frame onward, because the last material applied each frame was the glow shell. Specular goes back to the RIG's values rather than a hardcoded default, because that is what the scene asked for before any material overrode it.

method : private : ResetMaterialState() ~ Nil

SetCulling #

Whether to skip boxes that fall outside the view. On by default. The test is a few dozen arithmetic operations per box against a draw call plus a matrix multiply plus two uniform uploads, so it pays for itself almost immediately and costs almost nothing when it does not. Turn it off to measure what it is saving, or if a custom vertex shader moves geometry away from where its box says it is -- the test uses the box, and a shader that displaces vertices makes that a lie.

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

Parameters

NameTypeDescription
enabledBoolfalse to submit every box

SetEye #

Tell the scene where the camera is, so transparency can be ordered. Transparent surfaces must be drawn back to front: a nearer one drawn first writes depth and hides what is behind it. That needs a distance, and a distance needs an eye. Call it every frame: scene->SetEye(camera->GetPosition()); Without it transparent boxes still draw, in insertion order -- which is correct only by luck.

method : public : SetEye(eye:Vector3) ~ Nil

Parameters

NameTypeDescription
eyeVector3the camera position

SetLight #

Light the scene. Pass Nil to go back to an unlit shader's own behaviour. The light's uniforms are written once per Draw, not per object, since a directional light is the same for everything in the frame.

method : public : SetLight(light:Light) ~ Nil

Parameters

NameTypeDescription
lightLightthe light, or Nil

SetLightRig #

Light the scene with several lights rather than one. Takes precedence over SetLight. A rig with a point light or two is what a scene with a lamp in it wants; SetLight remains the shorter way to say "one directional light".

method : public : SetLightRig(rig:LightRig) ~ Nil

Parameters

NameTypeDescription
rigLightRigthe lights, or Nil to go back to SetLight

SetShader #

Draw with a different program from here on. Resets what was learned about the old shader's uniforms -- a Scene that cached "this shader has no model matrix" and then had a lit shader substituted would silently stop transforming normals.

method : public : SetShader(shader:Shader) ~ Nil

Parameters

NameTypeDescription
shaderShaderthe program to draw with

SlideMove #

Move a camera through the scene, sliding along whatever it hits. The only program with collision could not use Camera->MoveForward or MoveRight, because collision has to test a candidate position BEFORE committing to it -- so it re-derived the ground basis, did the dot products by hand and called SetPosition, twenty lines that belong here. Each axis is applied separately, and that is the whole trick: testing the combined vector stops you dead against a wall you hit at an angle, while testing X and Z in turn lets the blocked component drop and the other one through, which is what sliding along a wall is. ## A step bigger than a wall goes through it This tests the DESTINATION, not the path swept to reach it. Ask it to move 2 units and the far side of a 1-unit wall is clear, so that is where you land -- no overlap is ever detected because the position that would have overlapped is never tested. Two things keep it from biting. GetDelta is clamped to 0.1, so the largest step a frame-rate hitch can produce is `speed * 0.1` rather than however long the machine stalled for. And the rule that follows: keep `speed * 0.1` comfortably under the thickness of your thinnest solid box. At a speed of 6 that is 0.6, so walls a unit thick are safe and paper-thin dividers are not. Also note what "solid" means. Blocked and this method both skip boxes marked SetSolid(false), and both ignore height entirely -- the test is a circle against a rectangle in plan view. A floor spans every (x,z) in the room, so a SOLID floor blocks every position in it and nothing can move at all. Mark floors and ceilings non-solid.

method : public : SlideMove(camera:Camera, forward:Float, strafe:Float, radius:Float) ~ Bool

Parameters

NameTypeDescription
cameraCamerathe camera to move
forwardFloatdistance along the camera's ground heading
strafeFloatdistance to the camera's right
radiusFloatthe mover's radius in plan view

Return

TypeDescription
Booltrue when either axis was blocked

SortFar #

Order the collected transparent boxes furthest-first. An insertion sort, which is the right choice rather than a lazy one: the count is small, and between frames the order barely changes, so a nearly sorted list costs close to one pass. Quicksort's advantage needs a size this will not reach, and a nearly sorted list is its worst case.

method : private : SortFar() ~ Nil

Parameters

NameTypeDescription