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
- New
- Add
- AddBatch
- AddBox
- AddProp
- Blocked
- Clear
- Draw
- DrawBatches
- DrawInto
- DrawOne
- EyeDistance
- Get
- GetBatch
- GetBatchCount
- GetCount
- GetCulledCount
- GetDrawnCount
- GetLight
- GetLightRig
- GetProp
- GetPropCount
- IsCulling
- PropEyeDistance
- Raycast
- RaycastFrom
- RaycastScreen
- RemoveBatch
- RemoveBox
- RemoveProp
- ReserveFar
- ResetMaterialState
- SetCulling
- SetEye
- SetLight
- SetLightRig
- SetShader
- SlideMove
- SortFar
Add #
Add an existing box.
method : public : Add(box:Box) ~ BoxParameters
| Name | Type | Description |
|---|---|---|
| box | Box | the box |
Return
| Type | Description |
|---|---|
| Box | the 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) ~ PropBatchParameters
| Name | Type | Description |
|---|---|---|
| batch | PropBatch | the batch; NOT owned by the scene, so Clear does not free it |
Return
| Type | Description |
|---|---|
| PropBatch | the same batch, for chaining |
AddBox #
Build and add a box.
method : public : AddBox(center:Vector3, half:Vector3, texture:Texture2D) ~ BoxParameters
| Name | Type | Description |
|---|---|---|
| center | Vector3 | the box's centre |
| half | Vector3 | half-extents on each axis |
| texture | Texture2D | the texture to draw it with |
Return
| Type | Description |
|---|---|
| Box | the 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) ~ PropParameters
| Name | Type | Description |
|---|---|---|
| prop | Prop | the prop to add |
Return
| Type | Description |
|---|---|
| Prop | the same prop |
Blocked #
Whether a circle in plan view hits any SOLID box.
method : public : Blocked(x:Float, z:Float, radius:Float) ~ BoolParameters
| Name | Type | Description |
|---|---|---|
| x | Float | circle centre x |
| z | Float | circle centre z |
| radius | Float | circle radius |
Return
| Type | Description |
|---|---|
| Bool | true 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() ~ NilDraw #
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[]) ~ NilParameters
| Name | Type | Description |
|---|---|---|
| view_projection | Float | the 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() ~ NilParameters
| Name | Type | Description |
|---|---|---|
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[]) ~ NilParameters
| Name | Type | Description |
|---|---|---|
| shader | Shader | the program to draw with |
| view_projection | Float | the 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() ~ NilParameters
| Name | Type | Description |
|---|---|---|
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() ~ FloatParameters
| Name | Type | Description |
|---|---|---|
Get #
method : public : Get(index:Int) ~ BoxParameters
| Name | Type | Description |
|---|---|---|
| index | Int | which box |
Return
| Type | Description |
|---|---|
| Box | the box, or Nil when out of range |
GetBatch #
method : public : GetBatch(index:Int) ~ PropBatchParameters
| Name | Type | Description |
|---|---|---|
| index | Int | 0 to GetBatchCount() - 1 |
Return
| Type | Description |
|---|---|
| PropBatch | the batch, or Nil if the index is out of range |
GetBatchCount #
method : public : GetBatchCount() ~ IntReturn
| Type | Description |
|---|---|
| Int | how many batches the scene holds |
GetCulledCount #
How many boxes the last Draw skipped as off-screen.
method : public : GetCulledCount() ~ IntReturn
| Type | Description |
|---|---|
| Int | the 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() ~ IntReturn
| Type | Description |
|---|---|
| Int | the count |
GetLightRig #
method : public : GetLightRig() ~ LightRigReturn
| Type | Description |
|---|---|
| LightRig | the rig, or Nil when none was set |
GetProp #
method : public : GetProp(index:Int) ~ PropParameters
| Name | Type | Description |
|---|---|---|
| index | Int | 0 to GetPropCount() - 1 |
Return
| Type | Description |
|---|---|
| Prop | the prop, or Nil if the index is out of range |
GetPropCount #
method : public : GetPropCount() ~ IntReturn
| Type | Description |
|---|---|
| Int | how many props this scene holds |
IsCulling #
method : public : IsCulling() ~ BoolReturn
| Type | Description |
|---|---|
| Bool | whether off-screen boxes are being skipped |
New # constructor
New(mesh:Mesh, shader:Shader)Parameters
| Name | Type | Description |
|---|---|---|
| mesh | Mesh | the cube mesh every box is drawn with; it must span -1..1 on each axis, or geometry and collision will silently disagree (see Box) |
| shader | Shader | the 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() ~ FloatParameters
| Name | Type | Description |
|---|---|---|
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) ~ RayHitParameters
| Name | Type | Description |
|---|---|---|
| origin | Vector3 | where the ray starts |
| direction | Vector3 | which way it points; normalised here, so it need not be |
| max_distance | Float | how far to look; a hit beyond this is a miss |
Return
| Type | Description |
|---|---|
| RayHit | a 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) ~ RayHitParameters
| Name | Type | Description |
|---|---|---|
| camera | Camera | the eye |
| max_distance | Float | how far to look |
Return
| Type | Description |
|---|---|
| RayHit | a 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) ~ RayHitParameters
| Name | Type | Description |
|---|---|---|
| camera | Camera | the eye |
| window | GLWindow | the window the mouse coordinates came from |
| x | Int | pixel across |
| y | Int | pixel down |
| max_distance | Float | how far to look |
Return
| Type | Description |
|---|---|
| RayHit | a 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) ~ BoolParameters
| Name | Type | Description |
|---|---|---|
| batch | PropBatch | the batch to drop; NOT freed |
Return
| Type | Description |
|---|---|
| Bool | true 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) ~ BoolParameters
| Name | Type | Description |
|---|---|---|
| box | Box | the box to drop |
Return
| Type | Description |
|---|---|
| Bool | true 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) ~ BoolParameters
| Name | Type | Description |
|---|---|---|
| prop | Prop | the prop to drop; it is NOT freed, since its mesh and material are usually shared |
Return
| Type | Description |
|---|---|
| Bool | true when it was in the scene |
ReserveFar #
Make room for count transparent boxes, growing only when it is not enough.
method : private : ReserveFar() ~ NilParameters
| Name | Type | Description |
|---|---|---|
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() ~ NilSetCulling #
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) ~ NilParameters
| Name | Type | Description |
|---|---|---|
| enabled | Bool | false 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) ~ NilParameters
| Name | Type | Description |
|---|---|---|
| eye | Vector3 | the 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) ~ NilParameters
| Name | Type | Description |
|---|---|---|
| light | Light | the 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) ~ NilParameters
| Name | Type | Description |
|---|---|---|
| rig | LightRig | the 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) ~ NilParameters
| Name | Type | Description |
|---|---|---|
| shader | Shader | the 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) ~ BoolParameters
| Name | Type | Description |
|---|---|---|
| camera | Camera | the camera to move |
| forward | Float | distance along the camera's ground heading |
| strafe | Float | distance to the camera's right |
| radius | Float | the mover's radius in plan view |
Return
| Type | Description |
|---|---|
| Bool | true 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() ~ NilParameters
| Name | Type | Description |
|---|---|---|