docs

Your First Game UI

Render HTML to an in-game texture using the CPU renderer.

On this page

This tutorial walks through rendering a web-page on the CPU, copying it to an engine texture, and forwarding native mouse and keyboard events.

๐Ÿ‘ Using the CPU renderer

The CPU renderer is the easiest way to startโ€” for rendering directly on the GPU, see GPU Renderer Overview.

1. Set Up the Platform

Ultralight delegates most platform-specific tasks to the Platform singleton for stuff like loading files (eg, file:// URLs), copying to the clipboard, and logging.

To help you get started, the (optional) AppCore module provides ready-made helpers for these handlers. (You can use these without calling App::Create() to retain control over your run loop.)

#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);
}

๐Ÿšง Set handlers before creating the renderer

You should register platform handlers before calling Renderer::Create(). If the library needs a font loader or file system and none is set, it exits the process with an error.

๐Ÿ“˜ Logging and custom handlers

The default logger writes to a fileโ€” to route messages to an engine log instead, register a custom Logger (see Logging and Console Messages). To provide a custom font loader or file system, see Setting Up the Platform.

2. Create the Renderer and View

Initialize the Renderer singleton, then create an offscreen View to load page content.

RefPtr<Renderer> renderer;
RefPtr<View> view;

void CreateRendererAndView() {
  ///
  /// Create our Renderer (call this only once per application, after
  /// setting up the Platform).
  ///
  renderer = Renderer::Create();

  ///
  /// Create an offscreen View, 500 by 500 pixels large. Views use the
  /// CPU renderer by default.
  ///
  view = renderer->CreateView(500, 500, ViewConfig(), nullptr);

  ///
  /// Load raw HTML into the View asynchronously.
  ///
  view->LoadHTML("<h1>Hello from your game!</h1>");
}
static ULRenderer renderer = NULL;
static ULView view = NULL;

void CreateRendererAndView(void) {
  ///
  /// Create our Renderer (call this only once per application, after
  /// setting up the Platform). The Config is passed here in C, and is
  /// destroyed once the Renderer exists.
  ///
  ULConfig config = ulCreateConfig();
  renderer = ulCreateRenderer(config);
  ulDestroyConfig(config);

  ///
  /// Create an offscreen View, 500 by 500 pixels large. Views use the
  /// CPU renderer by default (NULL selects the default session).
  ///
  ULViewConfig view_config = ulCreateViewConfig();
  view = ulCreateView(renderer, 500, 500, view_config, NULL);
  ulDestroyViewConfig(view_config);

  ///
  /// Load raw HTML into the View asynchronously.
  ///
  ULString html = ulCreateString("<h1>Hello from your game!</h1>");
  ulViewLoadHTML(view, html);
  ulDestroyString(html);
}

void Shutdown(void) {
  ///
  /// Destroy the View, then the Renderer (anything we create, we destroy).
  ///
  ulDestroyView(view);
  ulDestroyRenderer(renderer);
}

Renderer::CreateView() allocates an offscreen drawing surface at the given pixel dimensions, rendering on the CPU by default.

Load Content and Shut Down

View::LoadHTML() loads raw HTML strings asynchronously into the page. To load local files or remote pages instead, call View::LoadURL() (file:/// requests resolve through the registered file system).

When shutting down, destroy all active View instances before releasing the Renderer.

3. Run the Frame Loop

Updating the renderer inside the game loop requires three calls split between logic updates and frame presentation.

void UpdateLogic() {
  ///
  /// Give the library a chance to handle pending tasks and timers.
  /// Call this as often as you can from your game loop.
  ///
  renderer->Update();
}

void RenderOneFrame() {
  ///
  /// Notify the renderer that the main display has refreshed.
  ///
  renderer->RefreshDisplay(0);

  ///
  /// Render all active Views.
  ///
  renderer->Render();
}
void UpdateLogic(void) {
  ///
  /// Give the library a chance to handle pending tasks and timers.
  /// Call this as often as you can from your game loop.
  ///
  ulUpdate(renderer);
}

void RenderOneFrame(void) {
  ///
  /// Notify the renderer that the main display has refreshed.
  ///
  ulRefreshDisplay(renderer, 0);

  ///
  /// Render all active Views.
  ///
  ulRender(renderer);
}

Update Logic

Renderer::Update() processes internal timers, network tasks, and JavaScript callbacks. Call this as frequently as possible from your main game loop.

Present Frames

Renderer::RefreshDisplay(0) advances CSS animations, smooth scrolling, and requestAnimationFrame() callbacks. Call this once per frame.

Renderer::Render() repaints any active views that have changed since the last frame into their pixel surfaces.

For details on display IDs, presentation timestamps, and repaint scheduling, see Updating and Rendering.

4. Copy Pixels to an Engine Texture

When using the CPU renderer, check whether the view was repainted and copy the updated pixels to an engine texture.

void CopyToTexture() {
  Surface* surface = view->surface();
  if (surface->dirty_bounds().IsEmpty())
    return;  // nothing changed since the last upload

  // The default Surface is a BitmapSurface.
  RefPtr<Bitmap> bitmap = static_cast<BitmapSurface*>(surface)->bitmap();

  auto pixels = bitmap->LockPixelsSafe();  // unlocks at the end of scope
  UploadToEngineTexture(pixels.data(), bitmap->width(), bitmap->height(),
                        bitmap->row_bytes());

  surface->ClearDirtyBounds();
}
void CopyToTexture(void) {
  ///
  /// Get the View's Surface and check if the pixels have changed.
  ///
  ULSurface surface = ulViewGetSurface(view);
  if (ulIntRectIsEmpty(ulSurfaceGetDirtyBounds(surface)))
    return;

  ///
  /// Get the underlying Bitmap (the default Surface is a BitmapSurface,
  /// so cast to it). The Bitmap is owned by the Surface-- don't destroy it.
  ///
  ULBitmap bitmap = ulBitmapSurfaceGetBitmap((ULBitmapSurface)surface);

  ///
  /// Lock the Bitmap to access the raw pixels. The format is BGRA,
  /// 8 bits per channel, premultiplied alpha.
  ///
  void* pixels = ulBitmapLockPixels(bitmap);

  ///
  /// Pseudo-code, upload the pixels to one of your engine's textures here.
  ///
  UploadToEngineTexture(pixels, ulBitmapGetWidth(bitmap),
                        ulBitmapGetHeight(bitmap), ulBitmapGetRowBytes(bitmap));

  ///
  /// Unlock the Bitmap and clear the dirty bounds when we're done.
  ///
  ulBitmapUnlockPixels(bitmap);
  ulSurfaceClearDirtyBounds(surface);
}

Pixel Format and Stride

Dirty Bounds

Surface::dirty_bounds() returns the bounding rectangle of pixels that changed since the last call to Surface::ClearDirtyBounds(). If this rectangle is empty, nothing painted and you can skip the texture upload.

Pixel Format

Pixels are stored in 32-bit BGRA format with premultiplied alpha. Use Bitmap::row_bytes() for the row stride during upload (rows may be padded).

Surface Allocation

The View owns the Surface, so you should not destroy itโ€” to render directly into memory you own, see Render Surfaces.

5. Forward Input

Forward input events from the engine to the View so the page can respond to user actions.

C++
void OnEngineMouseMove(int x, int y) {
  ///
  /// Create a MouseEvent and pass it to the View.
  ///
  MouseEvent evt;
  evt.type = MouseEvent::kType_MouseMoved;
  evt.x = x;
  evt.y = y;
  evt.button = MouseEvent::kButton_None;

  view->FireMouseEvent(evt);
}

MouseEvent specifies the event type (kType_MouseMoved, kType_MouseDown, or kType_MouseUp), the button pressed, and the cursor position. Pass the struct to View::FireMouseEvent() to dispatch it to the page.

๐Ÿ“˜ Convert to logical pixels

Coordinates in MouseEvent must be in logical pixels relative to the top-left corner of the View. If the engine uses physical device pixels, divide the coordinates by View::device_scale() before firing the event.

Keyboard and Scroll Input

Keyboard and scroll events follow the same pattern through View::FireKeyEvent() and View::FireScrollEvent(). For details on constructing those events, see Mouse and Scroll Input and Keyboard Input.

6. Display the Texture on a Quad

Draw the texture to an on-screen quad to display it in your game (try to align texels with pixels for the sharpest results).

What's Next

Congrats! You should now have a live page of HTML rendering inside your game!

From here, Integrating into a Game Engine walks through GPU rendering and connecting UI events to game state.