docs

Render Surfaces

Read pixels from CPU Views or paint directly into a custom Surface.

On this page

A Surface is the pixel buffer the CPU renderer draws a View into, acting as its backbuffer.

By default, the library provides a bitmap surface— you read its pixels after each frame to display them.

You can also supply a custom surface so the library draws directly into memory it controls (such as a mapped GPU buffer, an OS framebuffer, or shared memory). Drawing straight into that memory avoids an intermediate pixel copy before displaying the frame.

📘 Custom Surfaces Require Renderer::Create()

AppCore manages surfaces automatically when you use App::Create(). To provide a custom Surface or SurfaceFactory, create the renderer using Renderer::Create() instead.

Using the Default Surface

Without a custom factory, each CPU View automatically receives a BitmapSurface backed by a Bitmap.

Reading View Pixels

After calling Renderer::Render(), cast View::surface() to BitmapSurface to retrieve the underlying Bitmap.

C++
///
/// Get the pixel-buffer Surface for a View.
///
Surface* surface = view->surface();

///
/// Cast it to a BitmapSurface.
///
BitmapSurface* bitmap_surface = (BitmapSurface*)surface;

///
/// Get the underlying bitmap.
///
RefPtr<Bitmap> bitmap = bitmap_surface->bitmap();

///
/// Use the bitmap here...
///

You can lock and read the bitmap as described in Working with Bitmaps.

Every surface type also provides Surface::LockPixelsSafe(), which returns an RAII guard that unlocks the pixel buffer when it goes out of scope.

Tracking Dirty Bounds

Surface::dirty_bounds() returns the pixel rectangle painted since the last call to Surface::ClearDirtyBounds().

If the dirty bounds are empty, you can skip updating the display. Dirty bounds accumulate until cleared— skipping an update preserves changes for the next frame.

Call Surface::ClearDirtyBounds() after copying the updated pixels to the display.

Copying Individual Dirty Rectangles

To transfer fewer pixels when separate regions of the page change, copy each dirty rectangle individually.

C++
void UploadDirtyRects(Surface* surface) {
  ///
  /// Copy each changed rectangle instead of their union to move fewer pixels.
  ///
  for (uint32_t i = 0; i < surface->dirty_rect_count(); ++i) {
    IntRect rect = surface->dirty_rect(i);

    // Pseudo-code, copy this rectangle to your destination here.
    CopyRectToTexture(surface, rect);
  }

  surface->ClearDirtyBounds();
}

Implementing a Custom Surface

To draw directly into native memory, inherit from Surface and implement its virtual member functions.

A custom surface stores 32-bit premultiplied BGRA pixels (BitmapFormat::BGRA8_UNORM_SRGB), and rows can include padding.

Method Description
width(), height() Return the dimensions in pixels.
row_bytes() Returns the byte stride between consecutive rows.
size() Returns the total allocation size in bytes.
LockPixels(), UnlockPixels() Lock the buffer to return a writable pixel pointer, then unlock it when drawing finishes.
Resize() Reallocates the buffer to a new width and height in pixels. Never called while pixels are locked.
Scroll() Shifts an existing rectangle of pixels within the buffer when the page scrolls.

Surface Lifecycle

The library calls SurfaceFactory::CreateSurface() whenever you create a CPU View, and calls SurfaceFactory::DestroySurface() when the View is destroyed.

During Renderer::Render(), the library calls LockPixels(), paints updated regions into the buffer, and calls UnlockPixels(). The painted area is then added to dirty_bounds().

Shifting Pixels on Scroll

When a page scrolls, the library calls Scroll() to move existing pixels rather than repainting the entire view.

This call shifts the source surface buffer, not the destination display texture. Calling Surface::ShiftPixels() handles the in-place pixel copy.

Return Value Description
false Default behavior. The library marks the shifted rectangle as dirty, and you re-copy the moved area to the destination.
true Used only if you shifted the destination texture by the same offset. The library marks only the newly exposed strip as dirty.

🚧 Pixels Are Locked During Scroll

The library calls Scroll() while pixels are locked. Shift pixels using the pointer returned by LockPixels(), and never call LockPixels() again inside Scroll().

Example: OpenGL PBO Surface

This custom surface implementation draws directly into an OpenGL pixel buffer object and syncs non-empty dirty bounds to a texture.

C++
///
/// Custom Surface implementation that allows Ultralight to paint directly
/// into an OpenGL PBO (pixel buffer object).
///
/// PBOs in OpenGL allow us to get a pointer to a block of GPU-controlled
/// memory for lower-latency uploads to a texture.
///
/// For more info: <http://www.songho.ca/opengl/gl_pbo.html>
///
class GLPBOTextureSurface : public Surface {
public:
  GLPBOTextureSurface(uint32_t width, uint32_t height) {
    Resize(width, height);
  }

  virtual ~GLPBOTextureSurface() {
    ///
    /// Destroy our PBO and texture.
    ///
    if (pbo_id_) {
      glDeleteBuffers(1, &pbo_id_);
      pbo_id_ = 0;
      glDeleteTextures(1, &texture_id_);
      texture_id_ = 0;
    }
  }

  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_; }

  virtual void* LockPixels() override { 
    ///
    /// Map our PBO to system memory so Ultralight can draw to it.
    ///
    glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pbo_id_);
    mapped_pixels_ = glMapBuffer(GL_PIXEL_UNPACK_BUFFER, GL_READ_WRITE);
    glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
    return mapped_pixels_;
  }

  virtual void UnlockPixels() override { 
    ///
    /// Unmap our PBO.
    ///
    glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pbo_id_);
    glUnmapBuffer(GL_PIXEL_UNPACK_BUFFER); 
    glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
    mapped_pixels_ = nullptr;
  }

  virtual bool Scroll(const IntRect& rect, int dx, int dy) override {
    ///
    /// Shift the pixels through the pointer we handed out in LockPixels()
    /// (mapping the PBO a second time would return null). Our upload copies
    /// the whole PBO, so return false and let the library report the
    /// shifted rectangle dirty too.
    ///
    Surface::ShiftPixels(mapped_pixels_, row_bytes_, rect, dx, dy);
    return false;
  }

  virtual void Resize(uint32_t width, uint32_t height) override {
    if (pbo_id_ && width_ == width && height_ == height)
      return;

    ///
    /// Destroy any existing PBO and texture.
    ///
    if (pbo_id_) {
      glDeleteBuffers(1, &pbo_id_);
      pbo_id_ = 0;
      glDeleteTextures(1, &texture_id_);
      texture_id_ = 0;
    }

    width_ = width;
    height_ = height;
    row_bytes_ = width_ * 4;
    size_ = row_bytes_ * height_;

    ///
    /// Create our PBO (pixel buffer object), with a size of 'size_'
    ///
    glGenBuffers(1, &pbo_id_);
    glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pbo_id_);
    glBufferData(GL_PIXEL_UNPACK_BUFFER, size_, 0, GL_DYNAMIC_DRAW);
    glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);

    ///
    /// Create our Texture object.
    ///
    glGenTextures(1, &texture_id_);
    glBindTexture(GL_TEXTURE_2D, texture_id_);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP);
    glBindTexture(GL_TEXTURE_2D, 0);
  }

  virtual GLuint GetTextureAndSyncIfNeeded() {
    ///
    /// This is NOT called by Ultralight.
    ///
    /// This helper function is called when our application wants to draw this
    /// Surface to an OpenGL quad. (We return an OpenGL texture handle)
    ///
    /// We take this opportunity to upload the PBO to the texture if the
    /// pixels have changed since the last call (indicated by dirty_bounds()
    /// being non-empty)
    ///
    if (!dirty_bounds().IsEmpty()) {
      ///
      /// Update our Texture from our PBO (pixel buffer object)
      ///
      glBindTexture(GL_TEXTURE_2D, texture_id_);
      glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pbo_id_);
      glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width_, height_,
        0, GL_BGRA, GL_UNSIGNED_BYTE, 0);
      glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
      glBindTexture(GL_TEXTURE_2D, 0);

      ///
      /// Clear our Surface's dirty bounds to indicate we've handled any
      /// pending modifications to our pixels.
      ///
      ClearDirtyBounds();
    }

    return texture_id_;
  }

protected:
  GLuint texture_id_ = 0;
  GLuint pbo_id_ = 0;
  void* mapped_pixels_ = nullptr;
  uint32_t width_;
  uint32_t height_;
  uint32_t row_bytes_;
  uint32_t size_;
};

Registering a Surface Factory

To supply custom surfaces to the renderer, subclass SurfaceFactory and implement CreateSurface() and DestroySurface().

C++
class GLTextureSurfaceFactory : public ultralight::SurfaceFactory {
public:
  GLTextureSurfaceFactory() {}

  virtual ~GLTextureSurfaceFactory() {}

  virtual ultralight::Surface* CreateSurface(uint32_t width,
                                             uint32_t height) override {
    ///
    /// Called by Ultralight when it wants to create a Surface.
    ///
    return new GLPBOTextureSurface(width, height);
  }

  virtual void DestroySurface(ultralight::Surface* surface) override {
    ///
    /// Called by Ultralight when it wants to destroy a Surface.
    ///
    delete static_cast<GLPBOTextureSurface*>(surface);
  }
};

Register the factory instance with Platform::instance().set_surface_factory() and keep it alive for the lifetime of the program.

C++
///
/// You should keep this instance alive for the duration of your program.
///
std::unique_ptr<GLTextureSurfaceFactory> factory(new GLTextureSurfaceFactory());

Platform::instance().set_surface_factory(factory.get());

🚧 Register Before Creating the Renderer

Register the factory before calling Renderer::Create() and before creating any views— each view receives its surface when created. The library provides a default factory, so failures occur only if you set the factory to null or if CreateSurface() returns null. In either case, the library logs an error and exits the process.

Displaying the Surface

To display the rendered content, cast View::surface() to the custom surface type and retrieve the texture handle.

C++
///
/// Get the Surface for a View.
///
Surface* surface = view->surface();

///
/// Cast it to a GLPBOTextureSurface.
///
GLPBOTextureSurface* texture_surface = (GLPBOTextureSurface*)surface;

///
/// Get the underlying texture handle.
///
GLuint texture_id = texture_surface->GetTextureAndSyncIfNeeded();

///
/// Use the texture here...
///

To display native graphics inside the page rather than rendering the page to a texture, see Displaying Custom Textures.