GPU Driver in C
Implement a custom GPU driver in C to render accelerated Views in your engine.
On this page
ULGPUDriver exposes the GPU driver interface to C applications, letting you render accelerated Views through your own graphics pipeline. Each pure virtual method from C++ corresponds to a function pointer field in the ULGPUDriver struct.
Before implementing a driver in C, read GPU Renderer Overview and Implementing a GPUDriver to understand the rendering architecture and pipeline requirements. This page focuses on what differs in C. For the memory and threading rules shared by all C headers, see C API Conventions. For registering other platform interfaces, see Platform Handlers in C.
Differences from C++
The C GPU driver replaces C++ virtual methods with a struct of function pointers and manages state outside class instances.
| C++ Feature | C API Equivalent | What Changes |
|---|---|---|
Subclass GPUDriver and call Platform::instance().set_gpu_driver() |
Populate ULGPUDriver and call ulPlatformSetGPUDriver() |
Pass the 15 function pointers by value in a struct that the library copies. |
| Store driver state in member variables | Callbacks have no user_data pointer |
Keep driver state at file scope instead. |
Override GetDeviceCaps() optionally |
get_device_caps can be NULL |
You must implement the other 14 callbacks. |
GPUState and Command use enum types |
Fields are stored as unsigned char |
Cast fields to their corresponding UL enum type before switching on them. |
Call Matrix::SetOrthographicProjection() then Transform() |
Call ulApplyProjection() |
Multiplies the transform by the orthographic projection in a single call. |
kGPUCompressedFormat_BC7 and GetBitmapFormatInfo() |
kULGPUCompressedFormat_BC7 and ulGetBitmapFormatInfo() |
Format flags and block geometry queries share the same meaning under C names. |
Installing the Driver
Populate a ULGPUDriver struct, register it before calling ulCreateRenderer(), and enable acceleration on each View you create:
#include <Ultralight/CAPI.h>
static ULRenderer renderer;
static ULView view;
void InstallGPUDriver(void) {
ULGPUDriver driver = {
.begin_synchronize = OnBeginSynchronize,
.end_synchronize = OnEndSynchronize,
.next_texture_id = OnNextTextureId,
.create_texture = OnCreateTexture,
.update_texture = OnUpdateTexture,
.destroy_texture = OnDestroyTexture,
.next_render_buffer_id = OnNextRenderBufferId,
.create_render_buffer = OnCreateRenderBuffer,
.destroy_render_buffer = OnDestroyRenderBuffer,
.next_geometry_id = OnNextGeometryId,
.create_geometry = OnCreateGeometry,
.update_geometry = OnUpdateGeometry,
.destroy_geometry = OnDestroyGeometry,
.update_command_list = OnUpdateCommandList,
.get_device_caps = OnGetDeviceCaps, // optional, can stay NULL
};
ulPlatformSetGPUDriver(driver);
}
void CreateAcceleratedView(void) {
ULConfig config = ulCreateConfig();
renderer = ulCreateRenderer(config);
ulDestroyConfig(config);
ULViewConfig view_config = ulCreateViewConfig();
ulViewConfigSetIsAccelerated(view_config, true);
view = ulCreateView(renderer, 1280, 720, view_config, NULL);
ulDestroyViewConfig(view_config);
}
Register your driver once before creating the renderer. Like all platform handlers, your callbacks and any file-scope state they access must remain valid until you destroy the renderer— see Platform Handlers in C.
📘 Reference Drivers Use C++
The reference drivers in the SDK platform folder (Direct3D 11, Direct3D 12, and OpenGL in C++, and Metal in Objective-C++) have no plain C versions. You can port the driver for your graphics API to C, or compile it in C++ and register it from C++ code— see GPU Renderer Overview.
Rendering Each Frame
On each frame, update the renderer, execute your saved drawing commands, and draw the View's render target texture:
void RenderOneFrame(void) {
ulUpdate(renderer);
ulRefreshDisplay(renderer, 0);
ulRender(renderer);
DrawCommandList(); // our function (see Replaying Commands)
ULRenderTarget target = ulViewGetRenderTarget(view);
if (!target.is_empty) {
// Pseudo-code, draw target.texture_id as a quad mapped with
// target.uv_coords here.
}
}
ulViewGetRenderTarget() returns a ULRenderTarget struct by value, so you don't need to destroy it.
Read the render target on every frame rather than caching its texture ID or dimensions, since both can change between frames. When drawing the quad, map its texture coordinates with uv_coords to account for padding— see GPU Renderer Overview.
Managing Textures and Geometry
Resource callbacks receive texture and geometry data borrowed for the duration of the call, so you must read or copy it before returning.
Creating Textures
Implement create_texture to inspect the incoming bitmap and allocate GPU storage:
static void OnCreateTexture(unsigned int texture_id, ULBitmap bitmap,
unsigned int flags) {
(void)texture_id;
if (flags & kULGPUTextureFlag_RenderTarget) {
// Pseudo-code, create a ulBitmapGetWidth(bitmap) x
// ulBitmapGetHeight(bitmap) render target here (no pixels to upload).
return;
}
void* pixels = ulBitmapLockPixels(bitmap);
if (ulBitmapIsCompressed(bitmap)) {
// Pseudo-code, upload ulBitmapGetSize(bitmap) bytes of blocks,
// ulBitmapGetRowBytes(bitmap) per row of blocks, in the plain UNORM
// format for ulBitmapGetFormat(bitmap) here.
} else {
// Pseudo-code, upload pixels, ulBitmapGetRowBytes(bitmap) per row,
// here.
}
(void)pixels;
ulBitmapUnlockPixels(bitmap);
}
When flags contains kULGPUTextureFlag_RenderTarget, the bitmap provides dimensions and a format but contains no pixel data. Check this flag first and allocate the render target without locking pixels— see Implementing a GPUDriver for available texture flags and formats.
Uploading Texture Data
Keep these rules in mind when handling bitmap data:
- Match every lock with an unlock. Every call to
ulBitmapLockPixels()requires a matchingulBitmapUnlockPixels()call on all code paths before returning. - Never destroy borrowed bitmaps. The
ULBitmaphandle passed tocreate_textureandupdate_textureis borrowed— never callulDestroyBitmap()on it. - Copy data for asynchronous uploads. Copy the locked bytes to a staging buffer or call
ulCreateBitmapFromCopy(). Destroy an owned copy created this way withulDestroyBitmap()once the upload finishes.
Handling Geometry and Render Buffers
In create_geometry and update_geometry, the ULVertexBuffer and ULIndexBuffer structs arrive by value, but their data pointers are borrowed for the call. If you upload vertex or index data asynchronously, copy the bytes before the callback returns.
In contrast, create_render_buffer receives a ULRenderBuffer struct that holds only scalar values— a texture ID, dimensions, and flags. It contains no data pointers, so there is nothing to copy or free.
Managing the Command List
The library batches all drawing operations into command lists dispatched during ulRender().
Saving the Command List
The update_command_list callback receives drawing commands during ulRender(), which you must copy before returning:
#include <Ultralight/CAPI.h>
#include <stdlib.h>
#include <string.h>
static ULCommand* pending = NULL;
static unsigned int pending_count = 0;
static void OnUpdateCommandList(ULCommandList list) {
pending_count = 0;
if (list.size == 0)
return;
ULCommand* grown = realloc(pending, list.size * sizeof(ULCommand));
if (!grown)
return;
pending = grown;
memcpy(pending, list.commands, list.size * sizeof(ULCommand));
pending_count = list.size;
}
list.commands points to internal memory that the library overwrites on subsequent calls.
Execute the saved commands after ulRender() completes, before starting the next frame.
Replaying Commands
To execute your saved commands, cast each byte-sized field to its enum type before switching on it:
static void ApplyState(const ULGPUState* state) {
ULShaderType shader = (ULShaderType)state->shader_type;
ULBlendFactor src = (ULBlendFactor)state->blend_src_factor;
ULBlendFactor dst = (ULBlendFactor)state->blend_dst_factor;
ULBlendEquation equation = (ULBlendEquation)state->blend_equation;
// Pseudo-code, bind state->render_buffer_id, the viewport, the shader
// pair, texture slots 1-4, the constant block (see below), the blend
// state, and the scissor here.
(void)shader;
(void)src;
(void)dst;
(void)equation;
}
void DrawCommandList(void) {
for (unsigned int i = 0; i < pending_count; i++) {
const ULCommand* cmd = &pending[i];
switch ((ULCommandType)cmd->command_type) {
case kCommandType_ClearRenderBuffer:
// Pseudo-code, clear cmd->gpu_state.render_buffer_id here (the
// rest of gpu_state doesn't apply).
break;
case kCommandType_DrawGeometry:
ApplyState(&cmd->gpu_state);
// Pseudo-code, draw cmd->indices_count indices of
// cmd->geometry_id from cmd->indices_offset here.
break;
case kCommandType_Flush:
// Pseudo-code, submit pending GPU work here (Direct3D 11 and
// OpenGL).
break;
}
}
pending_count = 0;
}
For what each command and state field requires, see Implementing a GPUDriver.
Building the Constant Buffer
Stock shaders read an 800-byte constant block matching the C++ Uniforms struct from GPU Shader Programs, which you populate from ULGPUState:
#include <Ultralight/CAPI.h>
#include <string.h>
typedef struct {
float state[4]; // 0, viewport width, viewport height, 1
ULMatrix4x4 transform; // the projected transform
int integer[8];
float scalar[8];
ULvec4 vector[8];
int clip_size[4]; // x = clip_size, the rest 0
ULMatrix4x4 clip[8];
} Uniforms;
_Static_assert(sizeof(Uniforms) == 800, "the stock shaders read 800 bytes");
void FillUniforms(Uniforms* u, const ULGPUState* state, bool flip_y) {
float width = (float)state->viewport_width;
float height = (float)state->viewport_height;
u->state[0] = 0.0f;
u->state[1] = width;
u->state[2] = height;
u->state[3] = 1.0f;
u->transform = ulApplyProjection(state->transform, width, height, flip_y);
memcpy(u->integer, state->uniform_integer, sizeof(u->integer));
memcpy(u->scalar, state->uniform_scalar, sizeof(u->scalar));
memcpy(u->vector, state->uniform_vector, sizeof(u->vector));
u->clip_size[0] = state->clip_size;
u->clip_size[1] = u->clip_size[2] = u->clip_size[3] = 0;
memcpy(u->clip, state->clip, sizeof(u->clip));
}
ulApplyProjection() multiplies state->transform by an orthographic projection matrix for the current viewport dimensions. Pass flip_y = true when targeting OpenGL.
🚧 Copy Constant Fields Individually
ULGPUStateis not laid out like the constant block. Its enum fields andclip_sizeare singleunsigned charbytes, and the C struct uses natural alignment instead of the packed C++ layout. Nevermemcpythe entire state struct into the constant buffer— copy each field individually as shown above.
Reporting Device Capabilities
The get_device_caps callback receives a pointer to a zero-initialized ULGPUDeviceCaps struct where you enable the features your graphics hardware supports:
static void OnGetDeviceCaps(ULGPUDeviceCaps* caps) {
caps->compressed_formats =
kULGPUCompressedFormat_BC1_BC3 | kULGPUCompressedFormat_BC7;
caps->max_texture_dimension = 16384;
}
Setting get_device_caps to NULL reports no optional capabilities and leaves all limits at their library defaults. For field definitions and default limits, see GPU Device Capabilities.
Compressed texture formats require the Pro edition or higher— see Compressed Textures.
Displaying Custom Textures
You can display engine textures on a page by calling your own next_texture_id function to reserve an identifier. Because the C API does not provide a getter for the installed driver, you must call your function directly rather than querying the platform.
Pass the reserved texture ID to ulCreateImageSourceFromTexture(). When a draw command in the command list references that ID, bind your native texture in your driver's draw handler.
For image source operations in C, see Views in C. To learn more about custom texture workflows, see Displaying Custom Textures.