Files
ELY-Browser/crates/ely_app/src/shell/web_surface_frame.rs
T

285 lines
10 KiB
Rust

use std::cell::RefCell;
use std::hash::Hasher;
use std::sync::Arc;
use ahash::AHasher;
#[cfg(target_os = "macos")]
use core_video::pixel_buffer::CVPixelBuffer;
use gpui::RenderImage;
use image::{ImageBuffer, Rgba};
use thiserror::Error;
use crate::services::servo_live::ServoLiveFrame;
thread_local! {
/// Single-slot cache of the most-recently-uploaded RGBA payload.
/// At 60 fps on a 1080p canvas the previous `from_parts` was
/// unconditionally building a fresh `Arc<RenderImage>` for every
/// tick — even when the bytes were bit-identical to the last
/// frame. The cache keys on a 64-bit hash of the raw bytes and
/// reuses the existing `Arc<RenderImage>` whenever the hash
/// matches, so steady-state idle pages no longer churn the GPUI
/// texture pool.
///
/// Uses `AHasher` instead of std's `DefaultHasher`. SipHash13
/// (default) tops out around ~1.5 GB/s; an 8 MB 1080p frame
/// hashes in ~5 ms on a modern CPU, which eats roughly 30 % of
/// the 16 ms scroll budget on every cache-miss frame. AHash
/// runs ~10 GB/s on the same hardware, dropping the per-frame
/// hash cost to ~0.8 ms and giving the scroll path back most of
/// that budget. Hash collisions remain ~1 in 2^64; if they ever
/// matter we'll trade in length + first/last 32 bytes as a
/// disambiguator before paying the full memcmp.
static LAST_FRAME_IMAGE: RefCell<Option<(u64, Arc<RenderImage>)>> =
const { RefCell::new(None) };
}
#[cfg(all(test, feature = "live-site-smoke"))]
use super::web_surface_geometry::WebSurfaceSize;
use super::web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollOffset};
#[derive(Clone)]
#[cfg_attr(not(all(test, feature = "live-site-smoke")), allow(dead_code))]
pub(super) struct WebSurfaceFrame {
pub(super) requested_url: String,
loaded_url: Option<String>,
title: Option<String>,
render_state: String,
width: u32,
height: u32,
scroll_offset: WebSurfaceScrollOffset,
zoom_percent: u16,
click_point: Option<WebSurfaceClickPoint>,
typed_text: Option<String>,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
content_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: u64,
/// Software-path image. Current GPUI builds require this for every
/// ready web frame because BGRA IOSurface presentation is still
/// held at the protocol boundary.
pub(super) image: Option<Arc<RenderImage>>,
/// Hardware-path companion imported from the sidecar. GPUI 0.2.2's
/// public `surface(...)` presenter accepts NV12 video buffers, and
/// Servo publishes BGRA IOSurfaces; this remains observability
/// state until a BGRA presenter is available.
#[cfg(target_os = "macos")]
pub(super) pixel_buffer: Option<CVPixelBuffer>,
}
impl WebSurfaceFrame {
pub(super) fn from_live_frame(
requested_url: String,
scroll_offset: WebSurfaceScrollOffset,
zoom_percent: u16,
frame: ServoLiveFrame,
) -> Result<Self, WebSurfaceError> {
#[cfg(target_os = "macos")]
let pixel_buffer = frame.pixel_buffer().cloned();
Self::from_parts(WebSurfaceFrameParts {
requested_url,
loaded_url: frame.loaded_url().map(str::to_string),
title: frame.title().map(str::to_string),
render_state: frame.render_state().to_string(),
width: frame.width(),
height: frame.height(),
scroll_offset,
zoom_percent,
click_point: None,
typed_text: None,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: frame.non_white_pixel_count(),
#[cfg(all(test, feature = "live-site-smoke"))]
content_pixel_count: frame.content_pixel_count(),
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: frame.sample_hash(),
rgba_bytes: frame.into_rgba_bytes(),
#[cfg(target_os = "macos")]
pixel_buffer,
})
}
fn from_parts(parts: WebSurfaceFrameParts) -> Result<Self, WebSurfaceError> {
if parts.rgba_bytes.is_empty() {
return Err(WebSurfaceError::MissingRenderablePayload);
}
// Servo's `read_pixels(gl::RGBA, gl::UNSIGNED_BYTE)` writes
// R-G-B-A in memory order. GPUI's `RenderImage` is documented
// as "in BGRA format" and uploads via
// `MTLPixelFormat::BGRA8Unorm`, which reads B-G-R-A. Hand the bytes across
// unchanged and the Metal sampler treats R as B (and vice
// versa) — every coloured pixel renders with R and B swapped.
// Swap once here so the rest of the pipeline (dedup hash,
// image buffer, GPU upload) all operate on the same BGRA
// representation.
let mut bytes = parts.rgba_bytes;
swap_red_blue_in_place(&mut bytes);
let bytes_hash = rgba_hash(&bytes);
let image = Some(resolve_render_image(parts.width, parts.height, bytes, bytes_hash)?);
Ok(Self {
requested_url: parts.requested_url,
loaded_url: parts.loaded_url,
title: parts.title,
render_state: parts.render_state,
width: parts.width,
height: parts.height,
scroll_offset: parts.scroll_offset,
zoom_percent: parts.zoom_percent,
click_point: parts.click_point,
typed_text: parts.typed_text,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: parts.non_white_pixel_count,
#[cfg(all(test, feature = "live-site-smoke"))]
content_pixel_count: parts.content_pixel_count,
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: parts.sample_hash,
image,
#[cfg(target_os = "macos")]
pixel_buffer: parts.pixel_buffer,
})
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn title_label(&self) -> String {
self.title.clone().unwrap_or_else(|| self.requested_url.clone())
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn url_label(&self) -> &str {
self.loaded_url.as_deref().unwrap_or(self.requested_url.as_str())
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn detail_label(&self) -> String {
let mut detail =
format!("{} {}", self.render_state(), self.scroll_offset.detail_label(self.size()));
if self.zoom_percent != ely_domain::DEFAULT_ZOOM_PERCENT {
detail = format!("{detail} zoom={}%", self.zoom_percent);
}
if let Some(click_point) = self.click_point {
detail = format!("{detail} {}", click_point.detail_label());
}
match self.typed_text.as_ref() {
Some(typed_text) => format!("{detail} text={}b", typed_text.len()),
None => detail,
}
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn size(&self) -> WebSurfaceSize {
WebSurfaceSize { width: self.width, height: self.height }
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn render_state(&self) -> &str {
self.render_state.as_str()
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn scroll_offset(&self) -> WebSurfaceScrollOffset {
self.scroll_offset
}
pub(super) fn zoom_percent(&self) -> u16 {
self.zoom_percent
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn click_point(&self) -> Option<WebSurfaceClickPoint> {
self.click_point
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn typed_text(&self) -> Option<&str> {
self.typed_text.as_deref()
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn non_white_pixel_count(&self) -> u64 {
self.non_white_pixel_count
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn content_pixel_count(&self) -> u64 {
self.content_pixel_count
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn sample_hash(&self) -> u64 {
self.sample_hash
}
}
struct WebSurfaceFrameParts {
requested_url: String,
loaded_url: Option<String>,
title: Option<String>,
render_state: String,
width: u32,
height: u32,
scroll_offset: WebSurfaceScrollOffset,
zoom_percent: u16,
click_point: Option<WebSurfaceClickPoint>,
typed_text: Option<String>,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
content_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: u64,
rgba_bytes: Vec<u8>,
#[cfg(target_os = "macos")]
pixel_buffer: Option<CVPixelBuffer>,
}
#[derive(Debug, Error)]
pub(super) enum WebSurfaceError {
#[error("invalid servo frame buffer for {width}x{height}")]
InvalidFrameBuffer { width: u32, height: u32 },
#[error(
"servo live frame did not include renderable pixels; BGRA IOSurface presentation is unavailable in GPUI 0.2.2"
)]
MissingRenderablePayload,
}
/// Swap byte 0 and byte 2 of every 4-byte pixel, converting Servo's
/// RGBA8 output into GPUI's expected BGRA8 in place. ~0.8 ms on a
/// 1080p frame; cheap relative to the AHash pass that follows.
fn swap_red_blue_in_place(bytes: &mut [u8]) {
for pixel in bytes.chunks_exact_mut(4) {
pixel.swap(0, 2);
}
}
fn rgba_hash(bytes: &[u8]) -> u64 {
let mut hasher = AHasher::default();
hasher.write(bytes);
hasher.finish()
}
fn resolve_render_image(
width: u32,
height: u32,
rgba_bytes: Vec<u8>,
bytes_hash: u64,
) -> Result<Arc<RenderImage>, WebSurfaceError> {
LAST_FRAME_IMAGE.with(|cache| -> Result<Arc<RenderImage>, WebSurfaceError> {
let mut cache = cache.borrow_mut();
if let Some((cached_hash, cached_image)) = cache.as_ref() {
if *cached_hash == bytes_hash {
return Ok(cached_image.clone());
}
}
let image_buffer = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, rgba_bytes)
.ok_or(WebSurfaceError::InvalidFrameBuffer { width, height })?;
let new_image = Arc::new(RenderImage::new([image::Frame::new(image_buffer)]));
*cache = Some((bytes_hash, new_image.clone()));
Ok(new_image)
})
}