./jerome{m}

~/Notes/Raylib 3D rendering.md · note · budding · 7 min · updated

Raylib for 3D

// status: budding

Raylib is Ramon Santamaria's "videogame programming without the fuss" library — a thin C layer over OpenGL (desktop 2.1/3.3/4.3, ES 2.0/3.0, or WebGL) that gets a triangle on screen in about ten lines. I used it in Raym to focus on the algorithm rather than the boilerplate, and these are the bits of its 3D side worth writing down.

Why pick raylib for 3D

  • Small C API — include raylib.h and link the compiled static/shared library. No framework-sized build-system archaeology.
  • Batteries included — windowing, input, audio, file I/O, image loading, and a math library (raymath.h). The similarly styled raygui is a separate companion library.
  • Readable internals — the code is genuinely small and well-commented. When you hit a weird behaviour, reading the source is faster than searching for docs.
  • Portability — same code compiles to desktop, web (emscripten), Android, and RPi.

Where it stops being the right tool: anything that needs a modern GPU pipeline (raylib's rlgl compute path targets OpenGL 4.3), large-scene culling, or a more involved lighting architecture. At that point, raw OpenGL or Vulkan gives you more control.

The hello-cube

c
#include "raylib.h"

int main(void) {
    InitWindow(1280, 720, "hello 3d");
    SetTargetFPS(60);

    Camera3D camera = {
        .position   = { 6.0f, 6.0f, 6.0f },
        .target     = { 0.0f, 0.0f, 0.0f },
        .up         = { 0.0f, 1.0f, 0.0f },
        .fovy       = 45.0f,
        .projection = CAMERA_PERSPECTIVE,
    };

    while (!WindowShouldClose()) {
        UpdateCamera(&camera, CAMERA_ORBITAL);

        BeginDrawing();
            ClearBackground(RAYWHITE);
            BeginMode3D(camera);
                DrawCube((Vector3){0,0,0}, 2,2,2, MAROON);
                DrawCubeWires((Vector3){0,0,0}, 2,2,2, BLACK);
                DrawGrid(10, 1.0f);
            EndMode3D();
            DrawFPS(10, 10);
        EndDrawing();
    }
    CloseWindow();
    return 0;
}

Everything inside BeginMode3D(camera) / EndMode3D() uses the 3D pipeline. Outside, it's 2D and UI.

Cameras

Camera3D is a plain struct. Five built-in modes via UpdateCamera(&camera, mode):

  • CAMERA_FREE — fly-through with mouse + WASD + E/Q.
  • CAMERA_ORBITAL — auto-orbits the target. Great for demos.
  • CAMERA_FIRST_PERSON — mouselook + WASD, stays on ground.
  • CAMERA_THIRD_PERSON — follows a target with mouse orbit.
  • CAMERA_CUSTOM — do nothing, you drive it yourself.

For anything real, bypass UpdateCamera and move position/target yourself — the built-in modes are prototype-grade. The camera exposes its matrices via GetCameraMatrix and GetCameraProjectionMatrix when you need them.

What to draw

Two families of API sit on top of the same mesh pipeline:

  • Immediate-mode primitives — DrawCube, DrawSphere, DrawCylinder, DrawPlane, DrawLine3D, DrawRay, DrawBoundingBox, DrawGrid. Good for debug viz, placeholder geometry, and gizmos. These helpers submit geometry through rlgl's internal path; reuse a Mesh or Model when persistent geometry is the better fit.
  • Meshes & models — persistent GPU buffers. Mesh, Material, and Model structs. This is what you want for anything drawn more than once.

DrawGrid and DrawRay are the two I keep reaching for while debugging. Any time a 3D coordinate seems off, dropping a DrawSphere at that point answers it faster than a printf.

Meshes

A Mesh is a flat struct of CPU-side geometry arrays plus GPU object IDs:

c
typedef struct Mesh {
    int vertexCount;
    int triangleCount;

    float *vertices;          // positions  (xyz)
    float *texcoords;         // (uv)
    float *normals;           // (xyz)
    float *colors;            // optional (rgba)
    unsigned short *indices;  // optional

    // ... boneIds, boneWeights, tangents, texcoords2, animVertices, animNormals ...

    unsigned int vaoId;
    unsigned int *vboId;
} Mesh;

UploadMesh(&mesh, dynamic) copies CPU-side arrays to GPU buffers. UpdateMeshBuffer(mesh, i, data, size, offset) patches one buffer for dynamic geometry. UnloadMesh(mesh) frees both sides.

Built-in generators for the usual primitives:

c
Mesh sphere   = GenMeshSphere(1.0f, 32, 32);
Mesh torus    = GenMeshTorus(0.3f, 1.0f, 32, 24);
Mesh knot     = GenMeshKnot(1.0f, 2.0f, 32, 24);
Mesh plane    = GenMeshPlane(10, 10, 10, 10);            // subdivided
Mesh hf       = GenMeshHeightmap(imgHeight, (Vector3){16,4,16});
Mesh cubicmap = GenMeshCubicmap(imgMap,    (Vector3){1,1,1});

GenMeshHeightmap and GenMeshCubicmap are the "terrain in one line" shortcuts — the first turns a grayscale image into a plane with displaced Y, the second turns a black-and-white image into blocky stacked cubes. For anything organic, generate your own vertex arrays and call UploadMesh.

Models & materials

A Model bundles one or more meshes with materials and an optional skeleton:

c
Model model = LoadModel("castle.glb");
model.materials[0].maps[MATERIAL_MAP_DIFFUSE].texture = LoadTexture("castle_diffuse.png");

DrawModel(model, (Vector3){0,0,0}, 1.0f, WHITE);
UnloadModel(model);

Depending on the build configuration, current raylib supports OBJ, IQM (skeletal animation), glTF/glb, VOX (MagicaVoxel), and M3D. glTF/glb is a practical interchange format for an asset pipeline.

A Material is a default-shader + up to 12 texture maps:

MapSlot
MATERIAL_MAP_DIFFUSE / ALBEDObase colour
MATERIAL_MAP_METALNESSmetallic
MATERIAL_MAP_NORMALnormal map
MATERIAL_MAP_ROUGHNESSroughness
MATERIAL_MAP_OCCLUSIONAO
MATERIAL_MAP_EMISSIONemissive
MATERIAL_MAP_HEIGHTheight/parallax
MATERIAL_MAP_CUBEMAPenvironment
MATERIAL_MAP_IRRADIANCEdiffuse IBL
MATERIAL_MAP_PREFILTERspecular IBL
MATERIAL_MAP_BRDFsplit-sum LUT

The stock shader is unlit and primarily handles the base texture and tint. For lighting or PBR, you ship a custom shader and wire the required maps and uniforms yourself. The raylib-extras organisation collects useful community examples and helpers.

Shaders

Shader is a vertex+fragment program pair loaded from files:

c
Shader sh = LoadShader("shaders/mine.vs", "shaders/mine.fs");
int locTime = GetShaderLocation(sh, "uTime");

model.materials[0].shader = sh;

// in the draw loop
float t = (float)GetTime();
SetShaderValue(sh, locTime, &t, SHADER_UNIFORM_FLOAT);

DrawModel(model, ...);

Locations raylib discovers automatically when a custom shader uses its conventional names:

  • mvp — full MVP matrix.
  • matModel, matView, matProjection — components.
  • colDiffuse — the tint passed to DrawModel.
  • texture0..texture2 — up to three samplers.

Other application-specific uniforms still need an explicit location. Camera-dependent examples commonly assign SHADER_LOC_VECTOR_VIEW themselves with GetShaderLocation(sh, "viewPos").

Pass NULL for either stage to use the built-in one:

c
Shader sh = LoadShader(NULL, "shaders/fragment.fs");  // default VS, custom FS

Cross-platform tip: the shader source still has to match the selected graphics backend. Raylib's examples usually choose separate GLSL 330 desktop and GLSL 100/300 ES files at compile time; LoadShaderFromMemory is useful when you prefer to assemble those variants in code.

Lighting — rlights.h

Raylib doesn't ship built-in lighting. The canonical sample adds examples/shaders/rlights.h, a small helper that plugs Blinn-Phong (or PBR, in the PBR sample) into a forward shader with up to four lights:

c
#include "rlights.h"

Shader sh = LoadShader("shaders/lighting.vs", "shaders/lighting.fs");

sh.locs[SHADER_LOC_VECTOR_VIEW] = GetShaderLocation(sh, "viewPos");
int ambientLoc = GetShaderLocation(sh, "ambient");
float ambient[4] = { 0.1f, 0.1f, 0.1f, 1.0f };
SetShaderValue(sh, ambientLoc, ambient, SHADER_UNIFORM_VEC4);

Light lights[MAX_LIGHTS] = {
    CreateLight(LIGHT_POINT,       (Vector3){-2,1, 2}, Vector3Zero(), YELLOW, sh),
    CreateLight(LIGHT_DIRECTIONAL, (Vector3){ 2,2,-2}, Vector3Zero(), WHITE,  sh),
};

For serious lighting you graduate to your own pipeline (see Deferred vs forward rendering). rlights.h is "demo quality" but perfect for small projects.

Textures & render targets

c
Texture2D tex   = LoadTexture("grass.png");
Image img       = LoadImage("grass.png");          // CPU-side
RenderTexture2D rt = LoadRenderTexture(1920, 1080); // FBO

Render-to-texture is the gateway to post-processing:

c
BeginTextureMode(rt);
    ClearBackground(BLACK);
    BeginMode3D(camera);
        DrawModel(model, ...);
    EndMode3D();
EndTextureMode();

BeginDrawing();
    ClearBackground(BLACK);
    BeginShaderMode(postFX);
        DrawTextureRec(rt.texture,
                       (Rectangle){0, 0, rt.texture.width, -rt.texture.height},  // Y flip!
                       (Vector2){0, 0}, WHITE);
    EndShaderMode();
EndDrawing();

The negative height is the Y-flip for the flipped FBO coordinate system — easy to forget and produces an "everything is upside down" bug that baffles for longer than it should.

Instancing

DrawMeshInstanced takes an array of transforms and a custom shader that reads them from the conventional instanceTransform mat4 attribute. Use raylib's semantic location instead of hard-coding a numeric attribute index:

c
#define N 10000
Matrix *transforms = MemAlloc(sizeof(Matrix) * N);
for (int i = 0; i < N; i++)
    transforms[i] = MatrixTranslate(randX(), 0, randZ());

Shader instanceSh = LoadShader("shaders/instance.vs", "shaders/default.fs");
instanceSh.locs[SHADER_LOC_MATRIX_MVP] = GetShaderLocation(instanceSh, "mvp");
instanceSh.locs[SHADER_LOC_VERTEX_INSTANCETRANSFORM] =
    GetShaderLocationAttrib(instanceSh, "instanceTransform");

Material mat = LoadMaterialDefault();
mat.shader = instanceSh;

DrawMeshInstanced(cubeMesh, mat, transforms, N);

The instance VS multiplies by instanceTransform before MVP. Raylib uploads the transforms into a VBO each call — for fully static instance sets you'll want your own persistent SSBO, at which point you're dropping into rlgl.

rlgl — the layer below

rlgl.h is Raylib's OpenGL abstraction layer: a thin wrapper that presents an immediate-mode interface (rlBegin, rlVertex3f, rlColor4ub, rlEnd) and batches draws internally. It is how DrawCube is implemented, and how you drop below the friendly API when you need to:

  • Build custom meshes vertex-by-vertex without touching OpenGL directly.
  • Push/pop matrix state (rlPushMatrix, rlRotatef, rlTranslatef) à la old GL.
  • Manage your own VBOs/VAOs/SSBOs (rlLoadVertexBuffer, rlLoadVertexArray).
  • Dispatch compute shaders (rlLoadComputeShaderProgram, rlComputeShaderDispatch) on GL 4.3+.

When you need a modern feature Raylib doesn't expose at the top level, rlgl is almost always where it lives. If that fails, you can mix raw OpenGL into a raylib program — the GL context is just there.

Custom mesh via rlgl

The cleaner path is usually: build float arrays, call UploadMesh, done. When you need immediate-mode for viz:

c
rlBegin(RL_TRIANGLES);
    rlColor4ub(255, 0, 0, 255);
    rlVertex3f(0, 0, 0);
    rlVertex3f(1, 0, 0);
    rlVertex3f(0, 1, 0);
rlEnd();

Raylib's internal batcher flushes these when the primitive/type/texture changes, or when EndMode3D/EndDrawing runs. Keep all your immediate-mode calls contiguous per material to avoid unnecessary flushes.

Skyboxes

Standard cube-map skybox — the examples/models/models_skybox_rendering.c sample is the reference:

  1. Load an image in a supported cubemap layout with LoadTextureCubemap, or convert an equirectangular panorama to a cubemap with a shader pass.
  2. Create a unit cube mesh and assign a material with a custom shader that samples the cubemap.
  3. Draw from inside the cube with back-face culling and depth writes temporarily disabled. The sample shader removes view translation so the skybox stays centred on the camera.

HDR equirectangular → cubemap conversion is also one example away, using a LoadShader pass and an FBO.

Things that tripped me up

  • Y axis on render textures — DrawTextureRec with height = -texture.height is the fix. The Raylib coordinate system is Y-up but FBO textures are stored Y-down.
  • Repeated high-detail primitives add up — for many spheres or other repeated shapes, profile the immediate helper and consider drawing a reused mesh or instanced model.
  • Material slot 0 is the common case — a successfully loaded model normally has a default material there, and most tutorials assume it. Multi-material models have the per-mesh material index in model.meshMaterial[i].
  • Model-relative assets matter — LoadModel resolves referenced textures from the model's directory. Keep the expected asset layout intact or wire textures explicitly.
  • Shader uniforms must be set while bound — SetShaderValue binds the shader implicitly, but cache the locations with GetShaderLocation once, not per-frame.
  • Y-up vs Y-down in glTF — both coordinates conventions appear in the wild. A flipped normal map usually means your tangent space disagrees with the asset's.
  • Render-state ownership — BeginMode3D switches projection/view state and enables depth testing. If you mix rlgl or raw OpenGL calls with Raylib helpers, verify the state at the point of use instead of assuming every state is reset.
  • Batch capacity is configurable — rlgl batches immediate-mode geometry, and compile-time settings such as RL_DEFAULT_BATCH_BUFFER_ELEMENTS control its capacity. Defaults can change between Raylib versions, so check the version you build against before tuning them.
  • rlgl compute is backend-specific — raylib exposes it for the OpenGL 4.3 backend, not its WebGL path. Cross-platform projects need another implementation for that work.

Ecosystem

  • raygui — immediate-mode UI in the same spirit.
  • raylib-extras — community extras: PBR, particle systems, glTF tooling, camera controllers.
  • rres — Raylib's resource packaging format.
  • raymath.h — the built-in math lib. Quaternions, matrices, SLERP, everything you'd want.

References

  • raylib.com — docs and API cheatsheet.
  • raylib examples — the models/ and shaders/ folders are the most useful.
  • Raylib cheatsheet — one-page API reference on the site.
  • Ramon Santamaria's talks on the philosophy and internals of Raylib.

Back to Notes · see also Ray Picking Through the Rendering Pipeline · real-time anti-aliasing · Debugging Graphics Without Guessing · Raym