Rendering to a Framebuffer
Render a page directly into framebuffer memory.
On this page
In this guide, you'll render a web-page directly to a framebuffer (for zero-copy display) and manipulate it from native code for best performance on resource-constrained devices.
1. Set Up the Platform
Platform Handlers
Ultralight delegates OS tasks like reading files and loading fonts to user-defined platform handlers set on the Platform singleton before creating the renderer.
At a minimum, you must provide a FileSystem and FontLoader before calling Renderer::Create().
AppCore provides default implementations for most operating systems so you can test your code quickly:
| Handler | Role | AppCore Helper |
|---|---|---|
| Font loader | Resolves system fonts for text rendering | GetPlatformFontLoader() |
| File system | Loads assets for file:/// URLs |
GetPlatformFileSystem() |
| Logger | Writes warnings and errors to a file | GetDefaultLogger() |
#include <Ultralight/Ultralight.h>
#include <AppCore/Platform.h>
using namespace ultralight;
void InitPlatform() {
///
/// Use the OS's native font loader.
///
Platform::instance().set_font_loader(GetPlatformFontLoader());
///
/// Use the OS's native file system, with a base directory of "./assets/".
/// All file:/// URLs will load relative to this base directory.
///
Platform::instance().set_file_system(GetPlatformFileSystem("./assets/"));
///
/// Use the default logger (writes to a log file).
///
Platform::instance().set_logger(GetDefaultLogger("ultralight.log"));
}
#include <AppCore/CAPI.h>
void InitPlatform(void) {
///
/// Use the OS's native font loader.
///
ulEnablePlatformFontLoader();
///
/// Use the OS's native file system, with a base directory of "./assets/".
/// All file:/// URLs will load relative to this base directory.
///
ULString base_dir = ulCreateString("./assets/");
ulEnablePlatformFileSystem(base_dir);
ulDestroyString(base_dir);
///
/// Use the default logger (writes to a log file).
///
ULString log_path = ulCreateString("ultralight.log");
ulEnableDefaultLogger(log_path);
ulDestroyString(log_path);
}
Paths and Error Logging
The base directory passed to GetPlatformFileSystem() is used to resolve all file:/// URLs. When you call View::LoadURL() with a file:/// address later, the path resolves against this directory.
Handler Lifetime and Custom Handlers
The library owns the handler singletons returned by AppCore— never destroy them in your code.
A device without system fonts can supply bundled fonts instead— see Custom Font Loading. If you're targeting an OS without AppCore support, you can implement custom handlers for each interface— see Setting Up the Platform.
2. Paint into Your Framebuffer
The Surface and Surface Factory
On the CPU renderer, every View paints into a user-defined Surface (provided by the SurfaceFactory registered on the Platform singleton). The default surface is a BitmapSurface that you'd have to copy to your destination.
A custom Surface lets the renderer paint straight into memory you own (such as a mapped framebuffer or compositor plane), eliminating the copy step.
Custom Surface Implementation
We'll define a bare-bones, non-resizable Surface implementation that maps framebuffer memory so that Ultralight can paint directly into it.
///
/// A Surface over the display's framebuffer. MapFramebuffer() and
/// UnmapFramebuffer() stand in for your platform's own calls (pseudo-code).
///
class FramebufferSurface : public Surface {
public:
FramebufferSurface(uint32_t width, uint32_t height) {
pixels_ = MapFramebuffer(width, height, &row_bytes_);
Resize(width, height);
}
virtual ~FramebufferSurface() { UnmapFramebuffer(pixels_); }
virtual uint32_t width() const override { return width_; }
virtual uint32_t height() const override { return height_; }
virtual uint32_t row_bytes() const override { return row_bytes_; }
virtual size_t size() const override { return size_; }
///
/// The library paints straight into the mapped framebuffer, so there is
/// nothing to lock or unlock.
///
virtual void* LockPixels() override { return pixels_; }
virtual void UnlockPixels() override {}
///
/// Shift the pixels in place. We present from dirty bounds, so
/// return false-- that tells the library to add the shifted rectangle to
/// the dirty bounds so we present it again.
///
virtual bool Scroll(const IntRect& rect, int dx, int dy) override {
Surface::ShiftPixels(pixels_, row_bytes_, rect, dx, dy);
return false;
}
///
/// The View is created at the display's size and never resized, so this
/// only records the size (the framebuffer's stride is row_bytes_).
///
virtual void Resize(uint32_t width, uint32_t height) override {
width_ = width;
height_ = height;
size_ = row_bytes_ * height_;
}
protected:
void* pixels_ = nullptr;
uint32_t width_ = 0;
uint32_t height_ = 0;
uint32_t row_bytes_ = 0;
size_t size_ = 0;
};
class FramebufferSurfaceFactory : public SurfaceFactory {
public:
virtual Surface* CreateSurface(uint32_t width, uint32_t height) override {
return new FramebufferSurface(width, height);
}
virtual void DestroySurface(Surface* surface) override {
delete static_cast<FramebufferSurface*>(surface);
}
};
///
/// Keep the factory alive for the life of the program, and register it
/// before creating the Renderer.
///
static FramebufferSurfaceFactory surface_factory;
void InitSurface() {
Platform::instance().set_surface_factory(&surface_factory);
}
#include <stdlib.h>
///
/// In C the Surface is a struct of our own that the library reaches through
/// the user data pointer, plus one callback per operation. MapFramebuffer()
/// and UnmapFramebuffer() stand in for your platform's own calls (pseudo-code).
///
typedef struct {
void* pixels;
unsigned int width;
unsigned int height;
unsigned int row_bytes;
size_t size;
} FramebufferSurface;
static void* CreateSurface(unsigned int width, unsigned int height) {
FramebufferSurface* self = (FramebufferSurface*)calloc(1, sizeof(*self));
self->pixels = MapFramebuffer(width, height, &self->row_bytes);
self->width = width;
self->height = height;
self->size = self->row_bytes * height;
return self;
}
static void DestroySurface(void* user_data) {
FramebufferSurface* self = (FramebufferSurface*)user_data;
UnmapFramebuffer(self->pixels);
free(self);
}
static unsigned int GetWidth(void* user_data) {
return ((FramebufferSurface*)user_data)->width;
}
static unsigned int GetHeight(void* user_data) {
return ((FramebufferSurface*)user_data)->height;
}
static unsigned int GetRowBytes(void* user_data) {
return ((FramebufferSurface*)user_data)->row_bytes;
}
static size_t GetSize(void* user_data) {
return ((FramebufferSurface*)user_data)->size;
}
///
/// The library paints straight into the mapped framebuffer, so there is
/// nothing to lock or unlock.
///
static void* LockPixels(void* user_data) {
return ((FramebufferSurface*)user_data)->pixels;
}
static void UnlockPixels(void* user_data) {
(void)user_data;
}
///
/// Shift the pixels in place. We present from dirty bounds, so
/// return false-- that tells the library to add the shifted rectangle to
/// the dirty bounds so we present it again.
///
static bool Scroll(void* user_data, ULIntRect rect, int dx, int dy) {
FramebufferSurface* self = (FramebufferSurface*)user_data;
ulSurfaceShiftPixels(self->pixels, self->row_bytes, rect, dx, dy);
return false;
}
///
/// The View is created at the display's size and never resized, so this
/// only records the size (the framebuffer's stride is row_bytes).
///
static void Resize(void* user_data, unsigned int width, unsigned int height) {
FramebufferSurface* self = (FramebufferSurface*)user_data;
self->width = width;
self->height = height;
self->size = self->row_bytes * height;
}
void InitSurface(void) {
///
/// The definition is passed by value, so there is no instance to keep
/// alive. Zero-initialize it so any callback left out reads as NULL, and
/// register it before creating the Renderer.
///
ULSurfaceDefinition definition = {0};
definition.create = CreateSurface;
definition.destroy = DestroySurface;
definition.get_width = GetWidth;
definition.get_height = GetHeight;
definition.get_row_bytes = GetRowBytes;
definition.get_size = GetSize;
definition.lock_pixels = LockPixels;
definition.unlock_pixels = UnlockPixels;
definition.resize = Resize;
definition.scroll = Scroll;
ulPlatformSetSurfaceDefinition(definition);
}
Registering the Factory
Pass your SurfaceFactory instance to Platform::instance().set_surface_factory(), and keep it alive for the life of your program.
In the sample above, MapFramebuffer() and UnmapFramebuffer() stand in for your platform's memory mapping functions.
🚧 Register the Factory Before Creating the Renderer
Register your surface factory on the Platform singleton before calling
Renderer::Create(). A factory registered after a View exists affects only Views created later, and the CPU renderer logs an error and exits ifCreateSurface()returns null.
Pixel Format
A Surface uses 32-bit BGRA pixels (8 bits per channel) with premultiplied alpha. Your display controller must accept this layout directly, or your application will need to convert the pixel data when presenting.
3. Create the Renderer and a View
Configuring the Renderer and Memory Profile
You should create only one Renderer over your application's lifetime. The renderer uses reference counting, and it must outlive every View created from it.
For memory-constrained devices, set Config::memory_profile to MemoryProfile::LowMemory. This profile returns freed memory to the OS promptly, packs allocations more densely, and collects garbage more eagerly.
The memory profile is read when the Renderer is created and again by each View at its creation. Pass your Config to Platform::instance().set_config() before calling Renderer::Create().
View Configuration
Calling Renderer::CreateView() creates an offscreen View sized to match your display dimensions (800 by 480 pixels in this example). Passing nullptr as the session selects the default persistent session.
Configure the View using ViewConfig before creating it:
| Option | Default | Description |
|---|---|---|
is_accelerated |
false |
Selects CPU rendering (false) or GPU acceleration (true). |
enable_javascript |
true |
Controls whether page scripts run. |
initial_device_scale |
1.0 |
Scales page units to pixels (1.0 for 100% zoom). |
Setting enable_javascript to false disables all <script> tags on the page. The DOM API and data bindings still work, letting you update the page directly from native code without running page scripts.
RefPtr<Renderer> renderer;
RefPtr<View> view;
void CreateRendererAndView() {
///
/// Pack allocations tightly and return freed memory to the OS promptly.
/// The profile is read once, when the Renderer is created.
///
Config config;
config.memory_profile = MemoryProfile::LowMemory;
Platform::instance().set_config(config);
///
/// Create our Renderer (call this only once per application, after
/// setting up the Platform and the surface factory).
///
renderer = Renderer::Create();
///
/// Create a View the size of the display, on the CPU renderer, with
/// JavaScript turned off. The page is updated from native code instead.
///
ViewConfig view_config;
view_config.is_accelerated = false;
view_config.enable_javascript = false;
view = renderer->CreateView(800, 480, view_config, nullptr);
///
/// Load raw HTML into the View asynchronously.
///
view->LoadHTML("<h1 id='status'>Starting</h1>");
}
static ULRenderer renderer = NULL;
static ULView view = NULL;
void CreateRendererAndView(void) {
///
/// Pack allocations tightly and return freed memory to the OS promptly.
/// The Config is passed here in C, and is destroyed once the Renderer
/// exists.
///
ULConfig config = ulCreateConfig();
ulConfigSetMemoryProfile(config, kMemoryProfile_LowMemory);
renderer = ulCreateRenderer(config);
ulDestroyConfig(config);
///
/// Create a View the size of the display, on the CPU renderer, with
/// JavaScript turned off (NULL selects the default session).
///
ULViewConfig view_config = ulCreateViewConfig();
ulViewConfigSetIsAccelerated(view_config, false);
ulViewConfigSetEnableJavaScript(view_config, false);
view = ulCreateView(renderer, 800, 480, view_config, NULL);
ulDestroyViewConfig(view_config);
///
/// Load raw HTML into the View asynchronously.
///
ULString html = ulCreateString("<h1 id='status'>Starting</h1>");
ulViewLoadHTML(view, html);
ulDestroyString(html);
}
void Shutdown(void) {
///
/// Destroy the View, then the Renderer (anything we create, we destroy).
///
ulDestroyView(view);
ulDestroyRenderer(renderer);
}
Loading Content and Teardown
Calling View::LoadHTML() begins an asynchronous load of raw markup. To load files from storage, call View::LoadURL()— any file:/// path resolves through the file system handler configured in step 1.
Always destroy all Views before destroying the Renderer. RefPtr handles destruction automatically when objects go out of scope in that order.
4. Run the Frame Loop
Updating and Rendering Each Frame
The renderer does not run a loop of its own— you control it from your display loop with three calls:
- Call
Renderer::Update()to dispatch pending tasks, timers, and internal callbacks. Timers are clocked by this call, so pausing your loop pauses timers as well. - Call
Renderer::RefreshDisplay()after your display's vsync, passing display id0. This call advances animations, smooth scrolling, andwindow.requestAnimationFrame(). - Call
Renderer::Render()to paint all active Views that require repainting into their surfaces.
In the code below, WaitForVSync(), PresentFramebuffer(), and running stand in for your platform's display synchronization and presentation loop.
void RunFrameLoop() {
while (running) {
///
/// Give the library a chance to handle pending tasks and timers.
///
renderer->Update();
///
/// After the display refreshes, advance animations and render every
/// View that changed into its Surface.
///
WaitForVSync();
renderer->RefreshDisplay(0);
renderer->Render();
///
/// Present the rectangle that changed, then clear the dirty bounds so
/// the next frame's changes are tracked.
///
Surface* surface = view->surface();
IntRect dirty = surface->dirty_bounds();
if (!dirty.IsEmpty()) {
PresentFramebuffer(dirty);
surface->ClearDirtyBounds();
}
}
}
void RunFrameLoop(void) {
while (running) {
///
/// Give the library a chance to handle pending tasks and timers.
///
ulUpdate(renderer);
///
/// After the display refreshes, advance animations and render every
/// View that changed into its Surface.
///
WaitForVSync();
ulRefreshDisplay(renderer, 0);
ulRender(renderer);
///
/// Present the rectangle that changed, then clear the dirty bounds so
/// the next frame's changes are tracked.
///
ULSurface surface = ulViewGetSurface(view);
ULIntRect dirty = ulSurfaceGetDirtyBounds(surface);
if (!ulIntRectIsEmpty(dirty)) {
PresentFramebuffer(dirty);
ulSurfaceClearDirtyBounds(surface);
}
}
}
📘 Call RefreshDisplay on Every Frame
Call
RefreshDisplay()on every frame you present, even whenView::needs_paint()returns false. Animations advance only during this call— a View whose only pending work is animation repaints only after it runs.
Presenting Dirty Bounds
After calling Render(), Surface::dirty_bounds() returns the rectangle of pixels that changed since your last call to ClearDirtyBounds().
If the dirty rectangle is empty (dirty.IsEmpty()), no pixels changed and you can skip presentation. When pixels change, present the damaged rectangle (or flip the framebuffer) and call Surface::ClearDirtyBounds() so the next frame's changes are tracked.
Dirty bounds accumulate until cleared, so a frame your application skips presenting is not lost.
Damage Regions for Compositors
Surface::dirty_bounds() is the union of up to eight smaller damage rectangles the library keeps track of, listed by Surface::dirty_rect_count() and Surface::dirty_rect(i).
A compositor that takes damage regions can update each rectangle separately to move fewer pixels (eg, on a scrolled page with a fixed header). If you only present the full union, you can ignore the list.
For details on custom refresh rates, display timestamps, and selective rendering, see Updating and Rendering.
5. Update the Page from Native Code
Listening for DOM Ready
We can manipulate the page's DOM directly via native code, avoiding the memory and performance overhead of JavaScript.
Register a LoadListener with View::set_load_listener() to receive OnDOMReady(). This callback runs when the document has finished parsing and is your first chance to modify and restyle the page.
#include <Ultralight/DOM.h>
class Dashboard : public LoadListener {
public:
virtual void OnDOMReady(View* caller, uint64_t frame_id,
bool is_main_frame, const String& url) override {
///
/// Ignore DOMReady events from child frames.
///
if (!is_main_frame)
return;
///
/// Look up the element and change its text, the way page script would.
///
dom::Document doc(caller);
dom::Element status = doc.getElementById("status");
status.textContent = "Ready";
}
};
static Dashboard dashboard;
void AttachDashboard() {
view->set_load_listener(&dashboard);
}
#include <Ultralight/CAPI/CAPI_DOMDocument.h>
#include <Ultralight/CAPI/CAPI_DOMElement.h>
static void OnDOMReady(void* user_data, ULView caller,
unsigned long long frame_id, bool is_main_frame,
ULString url) {
///
/// Ignore DOMReady events from child frames.
///
if (!is_main_frame)
return;
///
/// Look up the element and change its text, the way page script would.
/// Every handle we take belongs to this page, and we destroy what we
/// create.
///
ULDOMDocument doc = ulViewGetDOMDocument(caller);
ULDOMElement status = ulDOMDocumentGetElementById(doc, "status");
ulDOMElementSetTextContent(status, "Ready", 5);
ulDestroyDOMElement(status);
ulDestroyDOMDocument(doc);
}
void AttachDashboard(void) {
ulViewSetDOMReadyCallback(view, OnDOMReady, NULL, NULL);
}
Modifying Elements from Native Code
For more info regarding DOM traversal, styles, and event handling, see About the DOM API.
For values and lists that update frequently, About Data Bindings synchronizes native data models with the page automatically.
6. Forward Touch Input
Translating Touch to Mouse Events
Ultralight does not yet offer a touch API so you'll need to translate touch events into mouse events:
- A tap is a
MouseEvent::kType_MouseMovedevent to the touch point, followed by aMouseEvent::kType_MouseDownand aMouseEvent::kType_MouseUpwithMouseEvent::kButton_Left. - A drag is a series of
MouseEvent::kType_MouseMovedevents while the left button remains pressed.
void OnTouch(bool pressed, int x, int y) {
///
/// Move the pointer to the touch point first, so the page sees the same
/// sequence a mouse produces, then press or release the left button.
///
MouseEvent evt;
evt.x = x;
evt.y = y;
evt.type = MouseEvent::kType_MouseMoved;
evt.button = MouseEvent::kButton_None;
view->FireMouseEvent(evt);
evt.type = pressed ? MouseEvent::kType_MouseDown : MouseEvent::kType_MouseUp;
evt.button = MouseEvent::kButton_Left;
view->FireMouseEvent(evt);
}
Coordinate Systems and Other Input
Mouse coordinates use logical pixels relative to the View's top-left corner. If your panel uses physical device pixels, divide them by View::device_scale() before creating the event.
Keyboard and scroll events follow the same forwarding pattern through View::FireKeyEvent() and View::FireScrollEvent()— see Keyboard Input and Mouse and Scroll Input.
Application Startup Sequence
To start your application, call the setup functions in order:
InitPlatform()registers file loading, font resolution, and logging handlers.InitSurface()registers your custom surface factory.CreateRendererAndView()creates the renderer and allocates the view.AttachDashboard()registers the load listener to modify the DOM when ready.RunFrameLoop()starts the display loop to update and render frames.
From here, Embedded and Device UI covers memory tuning, native bridges, and hardware acceleration on devices with a GPU.