3D Buffer & Commands
Your view function receives a RenderBuffer3D each frame and populates it with drawing commands via the fluent Draw DSL. The renderer dispatches them in order.
What and Why
The buffer is a command list. You don't draw to the screen directly; you describe what to draw, and the renderer handles batching, state management, and submission to the backend. This keeps your view function pure and testable.
When to use
Every 3D game needs this. Your view function writes to RenderBuffer3D. The framework calls it once per frame.
The buffer lifecycle
// Your view function signature: three inputs (context, model, buffer),
// and it does its work by adding commands to the buffer
val view : GameContext -> 'Model -> RenderBuffer3D -> unit
The buffer is pre-cleared each frame. Add commands:
let view (ctx: GameContext) (model: Model) (buffer: RenderBuffer3D) =
buffer
.beginCamera(camera)
.model(model.PlayerModel, model.PlayerTransform)
.endCamera()
.drop()
.drop() at the end silences the unused-value warning. It does nothing.
Pipeline pattern
Every 3D view follows the same structure:
buffer
.beginCamera(camera) // start camera transform
.setAmbientLight ... // lighting setup
.addDirectionalLight ...
.model ... // geometry
.endCamera() // end camera transform
.drop() // terminal
_IMPORTANT_: Geometry drawn outside
.beginCamera(...)/.endCamera()renders in screen space. This is rarely what you want.
Geometry commands
One member set covers both backends; the buffer takes your backend's own mesh (Mesh / PrimitiveMesh), model, material, and transform types:
Member |
What it draws |
|---|---|
|
Single primitive mesh (deprecated on MonoGame: see Slices of shared buffers) |
|
Mesh or mesh slice: MonoGame; offsets address a part of a shared buffer |
|
A loaded model with authored materials |
|
Model with whole-model material override |
|
Model with per-mesh-part material override |
|
Skeletal animation: bone palette derived for you |
|
Animated model + material override |
|
Explicit bone palette (raylib only) |
|
Many copies of one mesh in one draw call; optional per-instance |
|
Instanced draw of a mesh or mesh slice: MonoGame |
|
Camera-facing quad; optional rotation (degrees around view axis), atlas sub-rect, blend mode |
|
Batched billboards; optional per-item arrays (null or short = defaults for those items) |
|
Debug line |
_TIP_: Use the instanced/batched variants when drawing many copies of the same thing. One draw call is faster than many.
Billboard details:
sourceRectis a pixel-space sub-rect of the texture (atlas/flipbook frame); an all-zero rect means the full texture.- Blended billboards draw in buffer order with no depth sorting: non-
Opaquemodes test depth but don't write it,Opaqueuses full depth. (Opaqueexists only on the MonoGameBlendModeDU; raylib'sRaylib_cs.BlendModehas no opaque member, so every raylib billboard blends and writes no depth.) - On MonoGame, a billboard batch draws every item with
textures[0](use an atlas plussourceRects); raylib honors per-item textures.
Slices of shared buffers (MonoGame)
The MonoGame content pipeline can build many models into one shared vertex/index buffer pair; each ModelMeshPart is then a slice of that buffer, addressed by the part's first vertex (baseVertex) and first index. .mesh(...)/.instanced(...) draw from offset 0; for a mesh wrapping a shared-buffer part, that renders the first part's triangles. Use the slice members for those meshes:
buffer.meshSlice(partMesh, transform, material, vertexOffset = baseVertex, startIndex = startIndex)
buffer.instancedSlice(partMesh, transforms, material, count, vertexOffset = baseVertex, startIndex = startIndex)
- The offsets default to
0: self-contained buffers (procedural primitives, raylib meshes) call the slice members unchanged. This is whyDraw.mesh/Draw.instancedare deprecated on MonoGame; raylib meshes are self-contained and keep them. - The mesh record must describe the part, not the whole shared buffer:
PrimitiveCountis the part's triangle count (the draw is sized by it) andBoundsis the part's local-space bounding sphere (the shadow pass frustum-culls by it). Both are read from the record, never from the shared buffer. - Shared buffers with more than 65,536 vertices need a 32-bit index buffer;
PrimitiveMesh.Indicesholds either element size, and the merged-parts pipeline widens automatically. Only a hand-built shared buffer requires you to pick the element size yourself. (The proceduralPrimitive3Dmeshes are 16-bit by construction; they are small, so the limit never applies to them.)
Building the part-describing records by hand is the tedious part; ModelParts.ofModel(model) does it for you. It resolves every mesh part of a content Model into a ModelPart: a zero-copy wrap of the model's shared buffers (with the part's PrimitiveCount and the mesh's bounding sphere, both already in the part's bone-local space), the part's VertexOffset/StartIndex, the part's absolute parent-bone transform, and a Material3D read from the part's baked effect. Results are cached per model instance, so calling it every frame is a dictionary hit:
let parts = ModelParts.ofModel(model)
for part in parts do
buffer.meshSlice(part.Mesh, transform, part.Material,
vertexOffset = part.VertexOffset, startIndex = part.StartIndex)
Content vertices are stored bone-local, so fold part.Bone in front of every world/instance transform (stock ModelMesh.Draw does this internally). part.Bone is Matrix.Identity for models without bones. Three things to keep in mind:
- Treat the returned array as read-only: it is the cached result shared by every caller, and mutating an element corrupts it for the model's lifetime. Copy it (
Array.map) when you need adjusted parts. ModelPartsis for static models only: the instanced path carries no bone palette, so skinned parts render in their bind pose.- For skinned models use
animatedModelInstancedinstead; see GPU Instancing for the instanced form and the grid-context shortcut.
Camera commands
Member |
Description |
|---|---|
|
Start 3D camera transform |
|
Start camera with explicit viewport/clear/post-process |
|
End camera transform |
Lighting commands
Member |
Description |
|---|---|
|
Set scene ambient light |
|
Add a directional light |
|
Add a point light |
|
Add a spot light |
Shadow commands
Member |
Description |
|---|---|
|
Set shadow map origin for this frame |
|
Enable shadow casting for subsequent geometry |
|
Disable shadow casting for subsequent geometry |
Escape hatches
.drawImmediate(...) flushes the batch, runs raw backend calls (rlgl/raylib, or MonoGame device access via SceneContext), and restores state. On MonoGame, also see .beginEffect(...)/.endEffect() (custom shading scope that inherits scene data). See Overview.
Camera config
Use .beginCameraWith(...) when you need viewport control, clear color, or post-process pass selection:
buffer
.beginCameraWith(Camera3D.render camera |> Camera3D.withClear Color.SkyBlue)
.model(model, transform)
.endCamera()
.drop()
Camera3DConfig fields:
Field |
Type |
Description |
|---|---|---|
|
|
The 3D camera (backend struct; same field shape on both) |
|
|
raylib: normalized screen coords (0-1); MonoGame: pixel coords. |
|
|
|
Lighting setup
Add lights before geometry. Within a camera block, lights affect all subsequent draws in that block:
buffer
.beginCamera(camera)
.setAmbientLight { Color = Color.White; Intensity = 0.3f }
.addDirectionalLight {
Direction = Vector3(-1f, -1f, -1f)
Color = Color.White
Intensity = 0.8f
CastsShadows = true
}
.addPointLight {
Position = Vector3(5f, 3f, 0f)
Color = Color.Yellow
Intensity = 1f
Radius = 10f
CastsShadows = false
ShadowBias = ValueNone
}
.model(model, transform)
.endCamera()
.drop()
_TIP_: You can call
.addPointLight(...)in a loop for dynamic lights.
Light scoping across camera blocks
In a single-camera buffer, lights are frame-global: every light command applies to every draw. In a buffer with more than one camera block, lights are scoped per camera block:
- Frame defaults: light commands emitted outside any camera block (before the first one, or between two) accumulate into the frame defaults.
- Reset: a block that issues its own light commands starts from the frame defaults, then applies its own commands in order (a later ambient overwrites the earlier one; directional, point, and spot lights append).
- Inherit: a block that issues no light commands inherits the running set: the previous block's lights plus any light commands emitted between the two blocks.
- After the last block: light commands emitted after the final
.endCamera()affect nothing.
Light state is tracked per light type, and a block can only add to the set it inherits; it cannot remove an inherited light. Shadows follow the same scoping: .setShadowOrigin(...) applies only to the block it appears in, and each block with shadow-casting lights renders its own shadow map.
See also
- Draw DSL: the full fluent draw surface (2D and 3D)
- Overview: Architecture and pipeline setup
- Lighting: Light types and configuration
- Materials: PBR material system
- Instancing: GPU instanced rendering
Mibo