Vendor a headless hardware RenderingContext for Servo

The first concrete step toward the T10 IOSurface zero-copy path
(plan in docs/t10-iosurface-plan.md). Before this commit the sidecar
process could only use `SoftwareRenderingContext` — CPU rasterising
plus an 8 MB RGBA readback per 1080p frame is most of where scroll
latency comes from after the file pipe (a80d039), Vec clone
(e02c0fd), texture dedup (7f3b8b4), and hash swap (3f184ee) have
all landed.

The blocker is purely architectural: `servo-paint-api 0.1` exposes
`OffscreenRenderingContext` only as a child of
`WindowRenderingContext`, which requires a `DisplayHandle +
WindowHandle`. The sidecar has no window. The underlying
`SurfmanRenderingContext` glue *can* drive a hardware adapter
against a `SurfaceType::Generic` offscreen surface, but its
constructor is private. Until the upstream PR lands, this commit
vendors the minimal slice of that glue into `ely_servo_host`:

  * `HardwareOffscreenContext::new(size)` uses
    `Connection::new() → create_adapter()` (real GPU, not the
    software adapter) and a `SurfaceType::Generic` offscreen
    surface. On macOS the surfman CGL backend backs that surface
    with an `IOSurface` — exactly the thing the IOSurface bridge
    in subsequent commits will reach for.
  * Implements `servo::RenderingContext` so it slots into
    `ServoBuilder::rendering_context` wherever the existing
    `SoftwareRenderingContext` does, with no other Servo-side
    knowledge.
  * Scope deliberately narrow: only the methods Servo's headless
    readback actually calls. `create_texture` /
    `destroy_texture` / `connection` / `refresh_driver` fall
    through to the trait's `None` defaults. `read_to_image` inlines
    the upstream `Framebuffer::read_framebuffer_to_image` helper so
    we don't reach for a private helper that may change shape.
  * No `RawWindowHandle` and no `RefreshDriver` — both belong to
    paths the headless sidecar doesn't take.

The whole thing is feature-gated on `hardware-render`. Default
builds compile zero new lines; the additional surfman / gleam /
glow / euclid / image / log deps are all `optional = true`. Sidecar
binary still uses `SoftwareServoHost` until a follow-up commit
threads the new context in behind a CLI flag.

A smoke test at `tests/hardware_rendering_context.rs` constructs
the context. On a host with a real GPU it returns `Ok`; on a no-GPU
CI host it logs the surfman cause and reports `ok` rather than
failing the suite — the test is guarding the wiring, not the
hardware availability. On this Mac it constructs cleanly.

The `expect_used` / `unwrap_used` workspace lints are honoured —
fallible reads return `None` instead of panicking, no `.expect()` /
`.unwrap()` survives in the vendored body. The two `unsafe` blocks
(loading GL function pointers via surfman's `get_proc_address`) are
the same blocks upstream uses, with `#[expect(unsafe_code)]` to
override the workspace `unsafe_code = "deny"` lint locally.

cargo test --bin ely_app: 120 passed.
cargo test -p ely_servo_host --features servo-engine,hardware-render
  --test hardware_rendering_context: 1 passed (constructs cleanly).
Pre-existing `manages_real_servo_webview_lifecycle` failure on
servo.org is unrelated (reproduces on b8795bf without this change,
already documented in T7's commit history).
This commit is contained in:
2026-05-10 20:50:59 -04:00
parent 3f184ee941
commit 048c5dfecd
5 changed files with 366 additions and 0 deletions
@@ -0,0 +1,304 @@
//! Headless hardware [`RenderingContext`] for Servo, vendored from
//! `servo-paint-api`'s private `SurfmanRenderingContext` and reshaped
//! so it can be constructed without a `RawWindowHandle`.
//!
//! Why this file exists: `servo-paint-api 0.1` exposes three
//! constructors — `SoftwareRenderingContext` (CPU-only),
//! `WindowRenderingContext` (requires `DisplayHandle + WindowHandle`),
//! and `OffscreenRenderingContext` (must be a child of a
//! `WindowRenderingContext`). The sidecar process has no window, so
//! none of the three works for us when we want **hardware**
//! rasterising. The underlying `SurfmanRenderingContext` glue *can*
//! drive a hardware adapter against a `SurfaceType::Generic`
//! offscreen surface — that's exactly what we need — but its
//! constructor is `fn new` (private). Until Servo accepts an upstream
//! PR exposing a headless hardware constructor, this file vendors the
//! minimal slice of glue we need.
//!
//! Scope kept deliberately narrow:
//!
//! * Only the methods `RenderingContext` requires for software-style
//! readback are vendored (`prepare_for_rendering`, `read_to_image`,
//! `size`, `resize`, `present`, `make_current`, `gleam_gl_api`,
//! `glow_gl_api`). `create_texture`/`destroy_texture`/`connection`
//! fall through to the trait's `None` defaults — Servo uses them
//! only when sharing surfman surfaces with its own compositor.
//! * No `RefreshDriver`. The sidecar drives its own polling loop.
//! * The reading path inlines `read_framebuffer_to_image` from the
//! same upstream file so we don't take a dependency on a private
//! helper that may change shape.
//!
//! This is feature-gated on `hardware-render`. The default build path
//! (and every existing test in this repo) keeps using
//! `SoftwareRenderingContext`; the hardware constructor only exists
//! when the feature is enabled, which is also when the additional
//! surfman/gleam/glow deps are pulled in.
#![cfg(feature = "hardware-render")]
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::Arc;
use dpi::PhysicalSize;
use euclid::Size2D;
use gleam::gl::{self, Gl};
use image::RgbaImage;
use servo::{DeviceIntRect, RenderingContext};
use surfman::chains::{PreserveBuffer, SwapChain};
use surfman::{
Connection, Context, ContextAttributeFlags, ContextAttributes, Device, Error as SurfmanError,
GLApi, NativeWidget, Surface, SurfaceAccess, SurfaceType,
};
/// A headless hardware-backed [`RenderingContext`].
///
/// Construct with [`HardwareOffscreenContext::new`]; drop normally to
/// release the surfman context, surface, and swap chain.
pub struct HardwareOffscreenContext {
size: Cell<PhysicalSize<u32>>,
inner: SurfmanInner,
swap_chain: SwapChain<Device>,
}
impl HardwareOffscreenContext {
/// Build a new hardware context with an offscreen
/// [`SurfaceType::Generic`] surface of the requested size.
///
/// Uses `Connection::new()` to pick the platform default
/// (CGL on macOS — which backs surfaces with `IOSurface`s —
/// EGL on Linux, WGL on Windows) and `create_adapter()` for the
/// real GPU adapter. Falls back nowhere: if the host can't give
/// us a hardware GL context, the returned `Err` carries the
/// surfman cause and the caller is expected to either retry with
/// the software path or surface the failure.
pub fn new(size: PhysicalSize<u32>) -> Result<Self, SurfmanError> {
let connection = Connection::new()?;
let adapter = connection.create_adapter()?;
let inner = SurfmanInner::new(&connection, &adapter)?;
let surfman_size = Size2D::new(size.width as i32, size.height as i32);
let surface = inner.create_surface(SurfaceType::Generic { size: surfman_size })?;
inner.bind_surface(surface)?;
inner.make_current()?;
let swap_chain = inner.create_attached_swap_chain()?;
Ok(Self { size: Cell::new(size), inner, swap_chain })
}
}
impl Drop for HardwareOffscreenContext {
fn drop(&mut self) {
let device = &mut self.inner.device.borrow_mut();
let context = &mut self.inner.context.borrow_mut();
let _ = self.swap_chain.destroy(device, context);
}
}
impl RenderingContext for HardwareOffscreenContext {
fn prepare_for_rendering(&self) {
self.inner.prepare_for_rendering();
}
fn read_to_image(&self, source_rectangle: DeviceIntRect) -> Option<RgbaImage> {
self.inner.read_to_image(source_rectangle)
}
fn size(&self) -> PhysicalSize<u32> {
self.size.get()
}
fn resize(&self, size: PhysicalSize<u32>) {
if self.size.get() == size {
return;
}
self.size.set(size);
let device = &mut self.inner.device.borrow_mut();
let context = &mut self.inner.context.borrow_mut();
let size = Size2D::new(size.width as i32, size.height as i32);
let _ = self.swap_chain.resize(device, context, size);
}
fn present(&self) {
let device = &mut self.inner.device.borrow_mut();
let context = &mut self.inner.context.borrow_mut();
let _ = self.swap_chain.swap_buffers(device, context, PreserveBuffer::No);
}
fn make_current(&self) -> Result<(), SurfmanError> {
self.inner.make_current()
}
fn gleam_gl_api(&self) -> Rc<dyn Gl> {
self.inner.gleam_gl.clone()
}
fn glow_gl_api(&self) -> Arc<glow::Context> {
self.inner.glow_gl.clone()
}
}
/// Trimmed mirror of `paint_api::rendering_context::SurfmanRenderingContext`.
///
/// Only the methods the public type above actually uses are kept; the
/// upstream original also wires up texture sharing, refresh drivers,
/// and several other knobs that Servo's compositor reaches into but
/// the embedder's headless readback path does not.
struct SurfmanInner {
gleam_gl: Rc<dyn Gl>,
glow_gl: Arc<glow::Context>,
device: RefCell<Device>,
context: RefCell<Context>,
}
impl Drop for SurfmanInner {
fn drop(&mut self) {
let device = &mut self.device.borrow_mut();
let context = &mut self.context.borrow_mut();
let _ = device.destroy_context(context);
}
}
impl SurfmanInner {
fn new(connection: &Connection, adapter: &surfman::Adapter) -> Result<Self, SurfmanError> {
let device = connection.create_device(adapter)?;
let flags = ContextAttributeFlags::ALPHA
| ContextAttributeFlags::DEPTH
| ContextAttributeFlags::STENCIL;
let gl_api = connection.gl_api();
let version = match &gl_api {
GLApi::GLES => surfman::GLVersion { major: 3, minor: 0 },
GLApi::GL => surfman::GLVersion { major: 3, minor: 2 },
};
let context_descriptor =
device.create_context_descriptor(&ContextAttributes { flags, version })?;
let context = device.create_context(&context_descriptor, None)?;
// Loading the GL function pointers requires unsafe ABI calls
// through surfman's `get_proc_address` — these are the same
// calls the upstream `SurfmanRenderingContext::new` makes,
// and they're sound for the same reason: surfman guarantees
// the returned function pointers match the requested API.
#[expect(unsafe_code)]
let gleam_gl = {
match gl_api {
GLApi::GL => unsafe {
gl::GlFns::load_with(|name| device.get_proc_address(&context, name))
},
GLApi::GLES => unsafe {
gl::GlesFns::load_with(|name| device.get_proc_address(&context, name))
},
}
};
#[expect(unsafe_code)]
let glow_gl = unsafe {
glow::Context::from_loader_function(|name| device.get_proc_address(&context, name))
};
Ok(Self {
gleam_gl,
glow_gl: Arc::new(glow_gl),
device: RefCell::new(device),
context: RefCell::new(context),
})
}
fn create_surface(
&self,
surface_type: SurfaceType<NativeWidget>,
) -> Result<Surface, SurfmanError> {
let device = &mut self.device.borrow_mut();
let context = &self.context.borrow();
device.create_surface(context, SurfaceAccess::GPUOnly, surface_type)
}
fn bind_surface(&self, surface: Surface) -> Result<(), SurfmanError> {
let device = &self.device.borrow();
let context = &mut self.context.borrow_mut();
device
.bind_surface_to_context(context, surface)
.map_err(|(err, mut surface)| {
let _ = device.destroy_surface(context, &mut surface);
err
})?;
Ok(())
}
fn create_attached_swap_chain(&self) -> Result<SwapChain<Device>, SurfmanError> {
let device = &mut self.device.borrow_mut();
let context = &mut self.context.borrow_mut();
SwapChain::create_attached(device, context, SurfaceAccess::GPUOnly)
}
fn make_current(&self) -> Result<(), SurfmanError> {
let device = &self.device.borrow();
let context = &self.context.borrow();
device.make_context_current(context)
}
fn framebuffer_id(&self) -> u32 {
let device = &self.device.borrow();
let context = &self.context.borrow();
device
.context_surface_info(context)
.unwrap_or(None)
.and_then(|info| info.framebuffer_object)
.map_or(0, |framebuffer| framebuffer.0.into())
}
fn prepare_for_rendering(&self) {
let framebuffer_id = self.framebuffer_id();
self.gleam_gl.bind_framebuffer(gleam::gl::FRAMEBUFFER, framebuffer_id);
}
/// Inlined copy of `Framebuffer::read_framebuffer_to_image` from
/// `paint-api`. Reads the bound framebuffer into a `Vec<u8>`,
/// flips it vertically (GL's origin is bottom-left, the rest of
/// the embedder expects top-left), and returns it as an
/// [`RgbaImage`]. Returns `None` if `RgbaImage::from_raw` rejects
/// the buffer (size mismatch); GL errors are logged but don't
/// abort the read — the caller can decide whether a corrupt
/// frame is recoverable.
fn read_to_image(&self, source_rectangle: DeviceIntRect) -> Option<RgbaImage> {
let framebuffer_id = self.framebuffer_id();
self.gleam_gl.bind_framebuffer(gl::FRAMEBUFFER, framebuffer_id);
// Working around an OSMesa headless bug carried forward from
// the upstream implementation, see servo/servo#18606.
self.gleam_gl.bind_vertex_array(0);
let mut pixels = self.gleam_gl.read_pixels(
source_rectangle.min.x,
source_rectangle.min.y,
source_rectangle.width(),
source_rectangle.height(),
gl::RGBA,
gl::UNSIGNED_BYTE,
);
let gl_error = self.gleam_gl.get_error();
if gl_error != gl::NO_ERROR {
log::warn!("GL error 0x{gl_error:x} after read_pixels in hardware offscreen context");
}
let source_rectangle = source_rectangle.to_usize();
let stride = source_rectangle.width().checked_mul(4)?;
let mirror = pixels.clone();
for y in 0..source_rectangle.height() {
let dst_start = y.checked_mul(stride)?;
let src_start = (source_rectangle.height().checked_sub(y + 1)?).checked_mul(stride)?;
let dst_end = dst_start.checked_add(stride)?;
let src_end = src_start.checked_add(stride)?;
if dst_end > pixels.len() || src_end > mirror.len() {
return None;
}
pixels[dst_start..dst_end].clone_from_slice(&mirror[src_start..src_end]);
}
RgbaImage::from_raw(
source_rectangle.width() as u32,
source_rectangle.height() as u32,
pixels,
)
}
}