Using a Custom GPUDriver

Ultralight can emit raw GPU geometry / low-level draw calls to paint directly on the GPU without an intermediate CPU bitmap. We recommend this integration method for best performance when animating many paths / images.

Renderer-Agnostic Architecture

Ultralight is designed to be renderer-agnostic. All draw calls are emitted via the GPUDriver interface and expected to be translated into various platform-specific GPU technologies (D3D, Metal, OpenGL, etc.).

This approach allows Ultralight to be integrated directly with the native renderer of your game.

GPUDriver API

The first step to using a custom GPUDriver is to subclass the GPUDriver interface and override all the virtual functions.

You'll need to handle tasks like creating a texture, creating vertex/index buffers, and binding shaders.

All calls to the GPUDriver interface are dispatched during Renderer::Render() but drawing is not performed immediately-- Ultralight queues drawing commands via GPUDriver::UpdateCommandList() and expects you to dispatch these yourself.

Using the GPU Renderer

Set up Platform Singleton

You should notify the library of your custom GPUDriver implementation by calling Platform::instance().set_gpu_driver() when setting up the Platform singleton.

#include <Ultralight/Ultralight.h>
  
using namespace ultralight;

void InitRenderer() {
  // Pseudo-code to create our custom Platform implementations
  MyGPUDriver* gpu_driver = new MyGPUDriver();
  MyFileSystem* file_system = new MyFileSystem();
  MyFontLoader* font_loader = new MyFontLoader();
  
  // Notify the Platform singleton of our implementations
  Platform::instance().set_gpu_driver(gpu_driver);
  Platform::instance().set_file_system(file_system);
  Platform::instance().set_font_loader(font_loader);

  // We can now create the Renderer singleton
  Ref<Renderer> renderer = Renderer::Create();
}

Enable GPU-Acceleration on a View

Views are unaccelerated by default (ie, they use the CPU renderer and paint to a Surface).

You should set ViewConfig::is_accelerated to true when creating a View to have it use the GPU renderer and paint to a RenderTarget instead.

RefPtr<View> view;

void CreateView() {
  ///
  /// Create our ViewConfig with GPU acceleration enabled.
  ///
  ViewConfig view_config;
  view_config.is_accelerated = true;
  
  ///
  /// Create an HTML view, 500 by 500 pixels large.
  ///
  view = renderer->CreateView(500, 500, view_config, nullptr);
  
  ///
  /// Load a raw string of HTML asynchronously into the View.
  ///
  view->LoadHTML("<h1>Hello World!</h1>");
}

Shader Programs

Ultralight relies on vertex and pixel shaders for CSS transforms and to draw things like borders, rounded rectangles, shadows, and gradients.

Right now we have only two shader types: kShaderType_Fill and kShaderType_FillPath.

Both use the same uniforms but have different vertex types.

Here are the reference implementations for Direct3D (HLSL):

Vertex Shader (HLSL)Pixel Shader (HLSL)
kShaderType_Fillv2f_c4f_t2f_t2f_d28f.hlslfill.hlsl
kShaderType_FillPathv2f_c4f_t2f.hlslfill_path.hlsl

If your engine uses a custom shader language, you'll need to port these files over.

Blending Modes

Ultralight uses a custom blend mode-- you'll need to use the same blending functions in your engine to get color-accurate results when dispatching DrawGeometry commands with blending enabled.

For reference, here is the render target blend description for the D3D11 driver:

  D3D11_RENDER_TARGET_BLEND_DESC rt_blend_desc;
  ZeroMemory(&rt_blend_desc, sizeof(rt_blend_desc));
  rt_blend_desc.BlendEnable = true;
  rt_blend_desc.SrcBlend = D3D11_BLEND_ONE;
  rt_blend_desc.DestBlend = D3D11_BLEND_INV_SRC_ALPHA;
  rt_blend_desc.BlendOp = D3D11_BLEND_OP_ADD;
  rt_blend_desc.SrcBlendAlpha = D3D11_BLEND_INV_DEST_ALPHA;
  rt_blend_desc.DestBlendAlpha = D3D11_BLEND_ONE;
  rt_blend_desc.BlendOpAlpha = D3D11_BLEND_OP_ADD;
  rt_blend_desc.RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;

Render Loop Integration

Within your application's main run loop, you should:

  1. Call Renderer::Update() as often as possible
  2. Call Renderer::Render() once per frame.
    1. The renderer will call into the GPUDriver interface during this time synchronize textures, render buffers, geometry, and command lists.
  3. After calling Renderer::Render(), you should consume any pending command lists (all drawing is performed via command lists).
  4. Get the texture handle for each View and display it on an on-screen quad.
void UpdateLogic() {
  // Calling Update() allows the library to service resource callbacks,
  // JavaScript events, and other timers.
  renderer->Update();
}

void RenderFrame() {
  renderer->Render();

  // Pseudo-code, consume any pending command-lists in your GPUDriver here.
  gpu_driver->DrawCommandListIfNeeded();
    
  // Pseudo-code, draw any View textures to on-screen quads here.
  DrawViewQuadsToScreen();
}

Getting the View Texture

Ultralight doesn't draw anything to the backbuffer-- all Views are drawn to an offscreen RenderTarget that you can display how you wish.

Just call View::render_target() to get the render target for a View.

RenderTarget rtt_info = view->render_target();

// Get the Ultralight texture ID, use this with GPUDriver::BindTexture()
uint32_t tex_id = rtt_info.texture_id;

// Textures may have extra padding-- to compensate for this you'll need
// to adjust your UV coordinates when mapping onto geometry.
Rect uv_coords = rtt_info.uv_coords;

Once you have this texture you can display it on-screen as a quad or projected onto some other geometry in-game.