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()` |

<!-- tabs:start -->
```cpp
#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"));
}
```
```c
#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);
}
```
<!-- tabs:end -->

### 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](/docs/2.0/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](/docs/2.0/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.

<!-- tabs:start -->
```cpp
///
/// 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);
}
```
```c
#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);
}
```
<!-- tabs:end -->

### 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 if `CreateSurface()` 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.

<!-- tabs:start -->
```cpp
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>");
}
```
```c
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);
}
```
<!-- tabs:end -->

### 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:

1. 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.
2. Call `Renderer::RefreshDisplay()` after your display's vsync, passing display id `0`. This call advances animations, smooth scrolling, and `window.requestAnimationFrame()`.
3. 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.

<!-- tabs:start -->
```cpp
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();
    }
  }
}
```
```c
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);
    }
  }
}
```
<!-- tabs:end -->

> 📘 Call RefreshDisplay on Every Frame
>
> Call `RefreshDisplay()` on every frame you present, even when `View::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](/docs/2.0/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.

<!-- tabs:start -->
```cpp
#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);
}
```
```c
#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);
}
```
<!-- tabs:end -->

### Modifying Elements from Native Code

For more info regarding DOM traversal, styles, and event handling, see [About the DOM API](/docs/2.0/about-the-dom-api).

For values and lists that update frequently, [About Data Bindings](/docs/2.0/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_MouseMoved` event to the touch point, followed by a `MouseEvent::kType_MouseDown` and a `MouseEvent::kType_MouseUp` with `MouseEvent::kButton_Left`.
- A drag is a series of `MouseEvent::kType_MouseMoved` events while the left button remains pressed.

```cpp
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](/docs/2.0/keyboard-input) and [Mouse and Scroll Input](/docs/2.0/mouse-and-scroll-input).

### Application Startup Sequence

To start your application, call the setup functions in order:

1. `InitPlatform()` registers file loading, font resolution, and logging handlers.
2. `InitSurface()` registers your custom surface factory.
3. `CreateRendererAndView()` creates the renderer and allocates the view.
4. `AttachDashboard()` registers the load listener to modify the DOM when ready.
5. `RunFrameLoop()` starts the display loop to update and render frames.

From here, [Embedded and Device UI](/docs/2.0/embedded-and-device-ui) covers memory tuning, native bridges, and hardware acceleration on devices with a GPU.
