A single-header C99 library for SDL3 that implements a "Vertex Pulling" rendering backend.
I wrote this to test the concepts from Mason Ramali's "It's Not About The API" talk. It abstracts the verbose SDL3 GPU API into a simple immediate-mode style API (similar to Raylib) while using modern bindless techniques under the hood.
- Single Header: Drop
sgl.hinto your project. - Vertex Pulling: Uses Storage Buffers (SSBO) instead of Vertex Buffers.
- Bindless Texture Arrays: Renders thousands of unique textures in a single draw call using a dynamic 2D Array.
- Unified 2D/3D Pipeline: Batches 2D UI and 3D primitives (Cubes) in the same pass using degenerate triangles.
- Automatic Asset Scaling: Automatically resizes loaded images to fit the texture array constraints using
SDL_ScaleSurface. - Backend Agnostic: Works on Vulkan, Metal, D3D12 (handled by SDL3).
- Simple Frustum Culling: Ignoring anything that falls outside the camera's field of view("frustum").
Instead of constantly binding Vertex Buffers and swapping Textures for every shape, this library uses a modern, data-driven approach:
- Allocates one massive Storage Buffer (for geometry) and one massive Texture Array (for bindless images) on the GPU at startup.
- Maps a pointer to CPU memory every frame using a Transfer Buffer.
- Writes raw instance data (
x, y, z, color, texture index, etc.) linearly to that pointer. - Uploads the geometry data in one massive batch when
sgl_EndDrawing()or a camera mode switch is triggered. - Pushes calculated Camera View/Projection Matrices via Uniforms (Binding 0).
- Uses Vertex Pulling in the Vertex Shader to generate geometry mathematically on the fly using
gl_VertexIndex. It uses "degenerate triangles" (snapping extra vertices to0.0) to seamlessly mix 6-vertex 2D quads and 36-vertex 3D cubes in the exact same draw call. - Uses Bindless-style Texture Fetching in the Fragment Shader. The shader reads the
texIndexfrom the instance data and pulls the exact image slice it needs from the globalsampler2DArray, completely eliminating CPU-side texture binding overhead.
SDL3 expects SPIR-V resources to be bound in specific sets and bindings. When writing your own GLSL shaders for sgl, use this table:
| Resource Type | GLSL Keyword | Set (Space) | Binding | Notes |
|---|---|---|---|---|
| Storage Buffers | buffer |
set = 0 |
binding = 0 |
Instance Data (Rects, Colors, Angles). Defined via SDL_BindGPUVertexStorageBuffers. |
| Uniform Buffers | uniform |
set = 1 |
binding = 0 |
Global Data (Screen Size, Camera). Defined via SDL_PushGPUVertexUniformData. |
| Textures | uniform sampler2DArray |
set = 2 |
binding = 0 |
A single massive array containing up to 256 texture layers. |
## Setup
You must use GLSL/HLSL shaders that read from a StructuredBuffer if you want to use your own shader.
Save these in a folder named shaders/.
Note
SGL now has embedded default shaders!
No longer need to ship or load a shaders/ folder. The library works out-of-the-box with a single header file.
You can write your own custom GLSL shaders to create special effects (like glowing outlines, CRT scanlines, or custom 3D lighting). If you write a custom shader, it must conform to the library's bindless memory layout.
Your vertex shader must read from the std430 instance storage buffer (Set 0) and apply the Camera matrix (Set 1). You generate the geometry mathematically using gl_VertexIndex.
#version 450
// --- SET 0: The SGL Instance Data ---
struct InstanceData {
vec4 rect; // x, y, z, size
vec4 params; // angle, ox, oy, unused
vec4 params2; // type, texIndex, p2, p3
vec4 color; // r, g, b, a
};
layout(std430, set = 0, binding = 0) readonly buffer Instances {
InstanceData data[];
} instances;
// --- SET 1: The Camera Matrix ---
layout(set = 1, binding = 0) uniform Uniforms {
mat4 mvp; // Model-View-Projection matrix from sgl_BeginMode
};
// Outputs to your Fragment Shader
layout(location = 0) out vec4 outColor;
layout(location = 1) out vec2 outUV;
layout(location = 2) flat out int outTexIndex;
void main() {
InstanceData inst = instances.data[gl_InstanceIndex];
// ... [Your custom Vertex Pulling logic goes here] ...
// Calculate localPos and outUV based on gl_VertexIndex
outColor = inst.color;
outTexIndex = int(inst.params2.y);
gl_Position = mvp * vec4(localPos, 1.0);
}If your custom shader needs to draw images, it must access SGL's global texture array (Set 2).
#version 450
layout(location = 0) in vec4 inColor;
layout(location = 1) in vec2 inUV;
layout(location = 2) flat in int inTexIndex;
// --- SET 2: The SGL Texture Array ---
layout(set = 2, binding = 0) uniform sampler2DArray globalTextures;
layout(location = 0) out vec4 outFragColor;
void main() {
vec4 baseColor = inColor;
// Apply texture if the instance has one
if (inTexIndex >= 0) {
baseColor *= texture(globalTextures, vec3(inUV.x, inUV.y, float(inTexIndex)));
}
// ... [Your custom pixel effects (e.g., color tinting, glowing, discarding) go here] ...
outFragColor = baseColor;
}Custom shaders must be pre-compiled to SPIR-V using glslc (included in the Vulkan SDK).
glslc shaders/custom.vert -o shaders/custom.vert.spv
glslc shaders/custom.frag -o shaders/custom.frag.spvthen, load and apply them in your C code using SGL's pipeline API:
// Load the compiled SPIR-V files
// Signature: sgl_LoadShader(filename, stage, num_uniforms, num_storage, num_samplers)
SDL_GPUShader* customVert = sgl_LoadShader("shaders/custom.vert.spv", SDL_GPU_SHADERSTAGE_VERTEX, 1, 1, 0);
SDL_GPUShader* customFrag = sgl_LoadShader("shaders/custom.frag.spv", SDL_GPU_SHADERSTAGE_FRAGMENT, 0, 0, 1);
// Create the pipeline
SDL_GPUGraphicsPipeline* customPipeline = sgl_CreatePipeline(customVert, customFrag);
while (!sgl_WindowShouldClose()) {
sgl_BeginDrawing();
// Set your custom pipeline
sgl_SetPipeline(customPipeline);
// Draw shapes with your special effects!
sgl_DrawRectangle(10, 10, 100, 100, (SGL_COLOR){255, 0, 0, 255});
// Reset back to SGL's default pipeline for normal drawing
sgl_SetPipeline(NULL);
sgl_DrawRectangle(150, 10, 100, 100, (SGL_COLOR){0, 255, 0, 255});
sgl_EndDrawing();
}In exactly one C file (e.g., main.c), define SGL_IMPLEMENTATION before including the header.
#define SGL_IMPLEMENTATION
#include "sgl.h"
int main(int argc, char** argv) {
sgl_InitWindow(800, 600, "SGL 2D/3D Example");
SGL_Camera cam2d;
sgl_CameraInit(&cam2d, 0.0f, 0.0f, 1.0f);
while (!sgl_WindowShouldClose()) {
sgl_CameraUpdate(&cam2d);
sgl_BeginDrawing();
sgl_BeginMode2D(&cam2d);
sgl_DrawRectangle(10, 10, 100, 100, (SGL_COLOR){0, 255, 0, 255});
sgl_DrawTriangle(400, 300, 50, (SGL_COLOR){255, 0, 0, 255});
sgl_EndMode2D();
sgl_EndDrawing();
}
sgl_Shutdown();
return 0;
}we can use sgl built in frustum culling by get the current View-Projection matrix and extract planes. then loop through the massive grid(or somthing else) then test bounding sphere (Radius 1.5 is safe for a 1.0 size cube).
- Example:
// Inside main loop
sgl_BeginDrawing();
sgl_BeginMode3D(&cam);
SGL_Frustum frustum;
sgl_ExtractFrustum(sgl_GetCurrentMatrix(), &frustum);
int cubesDrawn = 0;
for (int x = -50; x < 50; x++) {
for (int z = -50; z < 50; z++) {
Vec3 cubePos = { (f32)x * 2.0f, 0.0f, (f32)z * 2.0f };
if (sgl_FrustumContainsSphere(&frustum, cubePos, 1.5f)) {
sgl_DrawCube(cubePos, 1.0f, NULL, (SGL_COLOR){0, 255, 0, 255});
cubesDrawn++;
}
}
}
sgl_EndMode3D();
sgl_EndDrawing();
// Print to see how many cubes were actually pushed to the GPU
// SGL_Log("Cubes Drawn: %d / 10000", cubesDrawn);Link against SDL3 and the math library
gcc testbed/main.c -o main -lSDL3 -lm
./mainYou can override defaults by defining these before including the header:
#define SGL_MAX_INSTANCES 50000 // Increase max sprites per frame
#include "sgl.h"now sgl will reports:
- Graphics Backend: (e.g., Vulkan, Metal, Direct3D12). but mainly will outputing vulkan cause of the SPIRV assignment in the gpu create device.
// ENABLE_GPU_DEBUG is true in debug builds, false in release
SDL_GPUDevice *dev =
SDL_CreateGPUDevice(SDL_GPU_SHADERFORMAT_SPIRV, ENABLE_GPU_DEBUG, NULL);- Shader Details: How many Uniform and Storage Buffers are active per shader.
-
Manual Compilation: Shaders must be pre-compiled to SPIR-V using glslc (from the Vulkan SDK). Runtime GLSL compilation is not currently included in SDL3 core if want to use custom shaders.
-
Resource Limits: The default instance limit is 10,000 sprites per frame. Increase SGL_MAX_INSTANCES if you see flickering or missing sprites.
-
Coordinate System: The default shader assumes (0,0) is Top-Left. If you port shaders from OpenGL, you may need to flip the Y-axis calculation.
- Bindless Textures: Implemented
sampler2DArraywith a Free-List memory allocator. - 3D Support: Implemented
sgl_DrawCubeand 3D Camera support. - Sprite Sheets: Add
sgl_DrawTexturePart(Source Rect support). - Text Rendering: Implement Bitmap Fonts using the new Texture Array system.