Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
568 changes: 540 additions & 28 deletions crates/gosub_engine/src/engine/context.rs

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions crates/gosub_engine/src/engine/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ impl GosubEngine {
}
self.io_handle = Some(io_handle);

// Start metrics HTTP server (GET http://127.0.0.1:9090/metrics)
#[cfg(feature = "metrics")]
crate::metrics::start(9090);

// Start main engine run loop
let join_handle = self.run().map(|task| spawn_named("Engine runner", task));

Expand Down
31 changes: 20 additions & 11 deletions crates/gosub_engine/src/engine/tab/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ pub struct TabWorker {
desired_viewport: Viewport,
/// Set when a resize arrives while rendering. Causes an immediate re-render after finishing the current rendering.
dirty_after_inflight: bool,
/// Latest unprocessed mouse position; coalesced into one hit-test per frame tick.
pending_mouse_pos: Option<(f64, f64)>,
/// Current scroll offset in CSS pixels (updated by MouseScroll).
scroll_x: i32,
scroll_y: i32,
Expand Down Expand Up @@ -168,6 +170,7 @@ impl TabWorker {
committed_viewport: Default::default(),
desired_viewport: Default::default(),
dirty_after_inflight: false,
pending_mouse_pos: None,
scroll_x: 0,
scroll_y: 0,
runtime: TabRuntime::default(),
Expand Down Expand Up @@ -384,17 +387,8 @@ impl TabWorker {
ControlFlow::Continue
}
TabCommand::MouseMove { x: _x, y: _y } => {
#[cfg(feature = "pipeline")]
{
let (dirty, link_url) = self.context.update_hover(_x as f64, _y as f64);
if dirty {
self.runtime.dirty = true;
}
self.send_event(EngineEvent::HoverUrl {
tab_id: self.tab_id,
url: link_url,
});
}
// Coalesce: store the latest position and let tick_draw do one hit-test per frame.
self.pending_mouse_pos = Some((_x as f64, _y as f64));
#[cfg(not(feature = "pipeline"))]
{
self.runtime.dirty = true;
Expand Down Expand Up @@ -737,6 +731,21 @@ impl TabWorker {
/// Do a draw tick. This will be called based on the FPS that is requested
#[allow(unreachable_code)] // cfg-conditional tile-cache returns make the display-list path unreachable for some feature combos
async fn tick_draw(&mut self) -> anyhow::Result<()> {
// Drain the coalesced mouse position — at most one hit-test per frame tick.
#[cfg(feature = "pipeline")]
if let Some((mx, my)) = self.pending_mouse_pos.take() {
let (visual_dirty, url_changed, link_url) = self.context.update_hover(mx, my);
if visual_dirty {
self.runtime.dirty = true;
}
if url_changed {
self.send_event(EngineEvent::HoverUrl {
tab_id: self.tab_id,
url: link_url,
});
}
}

// Skip rendering when nothing has changed to avoid burning CPU at the tick rate.
if !self.runtime.dirty {
return Ok(());
Expand Down
23 changes: 10 additions & 13 deletions crates/gosub_render_pipeline/examples/pipeline_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use gosub_html5::document::document_impl::DocumentImpl;
use gosub_html5::parser::Html5Parser;
use gosub_interface::config::{HasCssSystem, HasDocument};
use gosub_interface::css3::CssSystem as _;
use gosub_interface::document::Document;
use gosub_interface::document::Document as _;
use gosub_shared::byte_stream::{ByteStream, Encoding};

use gosub_render_pipeline::common::browser_state::{BrowserState, WireframeState};
Expand Down Expand Up @@ -119,7 +119,7 @@ struct StageTimes {
painting: Duration,
}

fn run_pipeline_once(html: &str) -> StageTimes {
fn run_pipeline_once(html: &str, layouter: &mut TaffyLayouter) -> StageTimes {
let viewport_w = 1280.0_f64;
let viewport_h = 800.0_f64;

Expand All @@ -141,7 +141,6 @@ fn run_pipeline_once(html: &str) -> StageTimes {

// Stage 2: layout
let t = Instant::now();
let mut layouter = TaffyLayouter::new();
let layout_tree = layouter.layout(render_tree, Some(Dimension::new(viewport_w, viewport_h)), 1.0);
let layout_time = t.elapsed();

Expand All @@ -150,17 +149,11 @@ fn run_pipeline_once(html: &str) -> StageTimes {
// Stage 3: layering
let t = Instant::now();
let layer_list = LayerList::new(layout_tree);
let layer_list_arc = Arc::new(layer_list);
let layering_time = t.elapsed();

// Stage 4: tiling
let t = Instant::now();
let mut tile_list = TileList::new(
// LayerList::new takes ownership, so we clone the Arc-wrapped version
// via the field; the TileList keeps its own Arc<LayerList>.
Arc::try_unwrap(Arc::clone(&layer_list_arc)).unwrap_or_else(|arc| (*arc).clone()),
Dimension::new(256.0, 256.0),
);
let mut tile_list = TileList::new(layer_list, Dimension::new(256.0, 256.0));
tile_list.generate();
let tiling_time = t.elapsed();

Expand Down Expand Up @@ -229,8 +222,12 @@ fn main() {
fixture.name
);

// Warm up — one throw-away run to avoid cold-cache skew.
let _ = run_pipeline_once(&fixture.html);
// Each fixture gets its own layouter so the media store (images, SVGs) is warm
// for all timed iterations — we measure layout computation, not network I/O.
let mut layouter = TaffyLayouter::new();

// Warm up — one throw-away run to prime the media store and OS caches.
let _ = run_pipeline_once(&fixture.html, &mut layouter);

let mut rt_times = Vec::with_capacity(iterations);
let mut layout_times = Vec::with_capacity(iterations);
Expand All @@ -239,7 +236,7 @@ fn main() {
let mut paint_times = Vec::with_capacity(iterations);

for _ in 0..iterations {
let t = run_pipeline_once(&fixture.html);
let t = run_pipeline_once(&fixture.html, &mut layouter);
rt_times.push(t.render_tree);
layout_times.push(t.layout);
layer_times.push(t.layering);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ pub trait PipelineDocument: Send + Sync {
/// Returns the own (explicitly-set) value for `prop` on node `id`, without recursing.
fn get_own_style(&self, id: NodeId, prop: &StyleProperty) -> Option<Value>;

/// Discard the computed-style cache so the next `get_own_style` call re-evaluates
/// CSS selectors (including `:hover`) from scratch. No-op for backends that do
/// not cache styles.
fn clear_style_cache(&self) {}

/// Returns the computed value for `prop` on node `id`:
/// 1. own value if set,
/// 2. parent's computed value if the property is inherited,
Expand Down Expand Up @@ -274,6 +279,10 @@ where
self.cached_styles(id).get_own(prop).cloned()
}

fn clear_style_cache(&self) {
self.style_cache.lock().clear();
}

fn html_node_id(&self) -> Option<NodeId> {
let root = self.doc.root();
self.find_child_by_tag(root, "html")
Expand Down
27 changes: 21 additions & 6 deletions crates/gosub_render_pipeline/src/common/media/media_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ impl MediaStore {

/// Load the given media from src into the media store, and return the media ID. Will also store the media(id) in cache
/// so the next call with the same src will return the same media ID without reloading.
///
/// If the resource cannot be fetched or decoded, the default placeholder for the detected media
/// type is returned and the failure is cached so subsequent calls with the same URL skip the
/// network entirely.
pub fn load_media(&self, src: &str) -> anyhow::Result<MediaId> {
// Check if the media from src is already loaded into the cache. If so, return that
let h = hash_from_string(src);
Expand All @@ -96,13 +100,24 @@ impl MediaStore {

let result = self.load_media_from_source(src);

if let Ok(media_id) = result {
let mut cache = self.cache.write();
// Another thread may have inserted while we were loading — don't overwrite
cache.entry(h).or_insert(media_id);
}
let media_id = match result {
Ok(media_id) => media_id,
Err(e) => {
log::warn!("Failed to load media from '{}': {}", src, e);
// Cache the failure as the default image placeholder so the same URL is
// never re-fetched in this session (avoids repeated blocking I/O).
let fallback_id = DEFAULT_IMAGE_ID;
let mut cache = self.cache.write();
cache.entry(h).or_insert(fallback_id);
return Ok(fallback_id);
}
};

let mut cache = self.cache.write();
// Another thread may have inserted while we were loading — don't overwrite
cache.entry(h).or_insert(media_id);

result
Ok(media_id)
}

pub fn load_media_from_data(&self, media_type: MediaType, data: &[u8]) -> anyhow::Result<MediaId> {
Expand Down
44 changes: 38 additions & 6 deletions crates/gosub_render_pipeline/src/layouter/taffy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ use taffy::NodeId as TaffyNodeId;
const DEFAULT_FONT_SIZE: f64 = 16.0;
const DEFAULT_FONT_FAMILY: &str = "sans-serif";

// Cache key: (text, font_family, size_bits, line_height_bits, weight, max_width_bits).
// Floats are stored as their bit pattern so the tuple is Hash + Eq.
type MeasureKey = (String, String, u32, u32, i32, u32);

/// Layouter structure that uses taffy as layout engine
pub struct TaffyLayouter {
/// Generated taffy tree
Expand All @@ -44,6 +48,10 @@ pub struct TaffyLayouter {
/// call avoids keeping the guard alive across the full layout pass, which lets
/// other threads (e.g. the rasterizer) access the font collection between calls.
font_system: Arc<Mutex<ParleyFontSystem>>,
/// Memoized text measurements. Taffy calls the measure function 2-4× per node
/// (MinContent, MaxContent, actual width). Caching by (text, font, max_width)
/// eliminates the redundant Parley shaping calls.
measure_cache: HashMap<MeasureKey, Size<f32>>,
}

/// Context structures to pass to taffy measure functions so we can calculate the size of the text or images.
Expand Down Expand Up @@ -114,6 +122,7 @@ impl TaffyLayouter {
anon_container_map: HashMap::new(),
media_store: MediaStore::new(),
font_system,
measure_cache: HashMap::new(),
}
}

Expand Down Expand Up @@ -156,27 +165,46 @@ impl CanLayout for TaffyLayouter {
None => Size::MAX_CONTENT,
};

// Clone the Arc so the closure can lock the font system without holding a
// borrow of `self` while `self.tree` is also mutably borrowed.
// Clone the Arc and take the measure cache so the closure can capture them
// without holding a borrow of `self` while `self.tree` is mutably borrowed.
let font_system = Arc::clone(&self.font_system);
let mut measure_cache: HashMap<MeasureKey, Size<f32>> = std::mem::take(&mut self.measure_cache);

// Compute the layout with a measure function
if let Err(e) = self
.tree
.compute_layout_with_measure(self.root_id, size, |v_kd, v_as, v_ni, v_nc, v_s| {
// If taffy already knows both dimensions, no measurement needed.
if let (Some(w), Some(h)) = (v_kd.width, v_kd.height) {
return Size { width: w, height: h };
}

match v_nc {
// Calculate text node
Some(TaffyContext::Text(text_ctx)) => {
let max_width = if text_ctx.no_wrap {
// white-space: nowrap — measure at unlimited width so text never wraps
1_000_000_000.0
1_000_000_000.0_f64
} else {
match v_as.width {
AvailableSpace::Definite(width) => width as f64,
AvailableSpace::MaxContent => 1_000_000_000.0, // f64::MAX doesn't work. Seems some kind of overflow. Same goes for f32::MAX
AvailableSpace::MinContent => 0.0,
}
};

let cache_key: MeasureKey = (
text_ctx.text.clone(),
text_ctx.font_info.family.clone(),
(text_ctx.font_info.size as f32).to_bits(),
(text_ctx.font_info.line_height as f32).to_bits(),
text_ctx.font_info.weight,
(max_width as f32).to_bits(),
);
if let Some(&cached) = measure_cache.get(&cache_key) {
return cached;
}

// Acquire the font context for this measurement. The lock is
// released immediately after the call so other callers
// (e.g. the rasterizer) can interleave without contention.
Expand Down Expand Up @@ -211,12 +239,14 @@ impl CanLayout for TaffyLayouter {
width += (text_ctx.font_info.size * 0.3) as f32;
}

Size {
let result = Size {
width,
// Ceil height so the layout height matches the integer-pixel surface
// that pango creates (prevents descenders from overflowing the box).
height: text_layout.height.ceil() as f32,
}
};
measure_cache.insert(cache_key, result);
result
}
Err(_) => Size::ZERO,
}
Expand All @@ -231,8 +261,10 @@ impl CanLayout for TaffyLayouter {
})
{
log::error!("Failed to compute taffy layout: {:?}", e);
self.measure_cache = measure_cache;
return layout_tree;
}
self.measure_cache = measure_cache;

// Since we are not interested in taffy layout after this stage in the pipeline, we convert
// the taffy layout to a box model layout tree. This makes the rest of the pipeline
Expand Down Expand Up @@ -298,6 +330,7 @@ impl TaffyLayouter {

/// Generate the layout tree from the render tree
fn generate_tree(&mut self, render_tree: RenderTree, root_id: RenderNodeId) -> LayoutTree {
self.measure_cache.clear();
self.tree = TaffyTree::new();
// Taffy's built-in rounding snaps layout values to integer CSS pixels, which causes
// text containers to lose sub-pixel width (e.g. 52.344 → 52.0). This makes pango
Expand Down Expand Up @@ -570,7 +603,6 @@ impl TaffyLayouter {

if data.tag_name.eq_ignore_ascii_case("svg") {
let inner_html = layout_tree.render_tree.doc.inner_html(dom_node.node_id);

match self
.media_store
.load_media_from_data(MediaType::Svg, inner_html.into_bytes().as_slice())
Expand Down
12 changes: 12 additions & 0 deletions crates/gosub_render_pipeline/src/tiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,18 @@ impl TileList {
}
}

/// Like `new`, but accepts an already-`Arc`-wrapped `LayerList` — used by
/// the hover-repaint fast path to avoid cloning the layout tree.
pub fn from_arc(layer_list: Arc<LayerList>, dimension: Dimension) -> Self {
Self {
layer_list,
tiles: HashMap::new(),
arena: HashMap::new(),
next_node_id: Arc::new(RwLock::new(TileId::new(0))),
default_tile_dimension: dimension,
}
}

// @TODO: Optimize: remove all tiles that are empty
pub fn generate(&mut self) {
self.tiles.clear();
Expand Down
14 changes: 12 additions & 2 deletions crates/gosub_renderer_cairo/src/bin/pipeline-cairo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ Available key commands:
w Toggle wireframe
d Toggle debug hover
t Toggle tile grid
s Print hover timing stats to stdout

"
);
Expand Down Expand Up @@ -149,13 +150,16 @@ fn build_ui(
{
let bs = browser_state.clone();
motion_controller.connect_motion(move |_, x, y| {
let _t_total = gosub_shared::timing_guard!("cairo_hover.total");

let el_id = {
let state = bs.read();
let Some(ref tile_list) = state.tile_list else {
return;
};
let id = tile_list.read().layer_list.find_element_at(x, y);
id
let binding = tile_list.read();
let _t = gosub_shared::timing_guard!("cairo_hover.hit_test");
binding.layer_list.find_element_at(x, y)
};
let che = bs.read().current_hovered_element;

Expand Down Expand Up @@ -198,6 +202,7 @@ fn build_ui(

let mut state = bs.write();
if state.current_hovered_element != el_id {
let _t_change = gosub_shared::timing_guard!("cairo_hover.changed");
if let Some(id) = el_id {
if let Some(ref tile_list) = state.tile_list {
let binding = tile_list.read();
Expand Down Expand Up @@ -320,6 +325,11 @@ fn build_ui(
state.show_tilegrid = !state.show_tilegrid;
area.queue_draw();
}
key if key == gtk4::gdk::Key::s => {
gosub_shared::timing::TIMING_TABLE
.lock()
.print_timings(false, gosub_shared::timing::Scale::Auto);
}
_ => (),
}

Expand Down