#![deny(unsafe_code)]
#[cfg(not(target_arch = "wasm32"))]
mod cost_tuner;
mod effect_renderer;
mod frame_graph;
mod frame_packet;
mod frontend;
pub(crate) mod gpu_stats;
mod layer_events;
mod layer_surface_cache;
mod normalized_scene;
mod offscreen;
mod pipeline;
mod render;
mod run_entry;
mod scene;
mod shader_cache;
mod shaders;
#[cfg(not(target_arch = "wasm32"))]
mod shape_replay;
#[cfg(not(target_arch = "wasm32"))]
mod stage_executor;
mod surface_executor;
mod surface_plan;
mod surface_requirements;
#[cfg(test)]
mod test_support;
#[doc(hidden)]
pub use frame_packet::{CancelReason, PresentOutcome};
pub use gpu_stats::FrameStatsSnapshot as RenderStatsSnapshot;
#[doc(hidden)]
#[cfg(not(target_arch = "wasm32"))]
pub use pipeline::retained_feed_generation;
pub use scene::{ClickAction, HitRegion, Scene};
#[doc(hidden)]
#[cfg(not(target_arch = "wasm32"))]
pub use shape_replay::feed_live_stats as command_feed_live_stats;
#[doc(hidden)]
#[cfg(not(target_arch = "wasm32"))]
pub use shape_replay::{inject_feed_capture_for_tests, pending_feed_capture_count_for_tests};
#[doc(hidden)]
#[cfg(not(target_arch = "wasm32"))]
pub use shape_replay::{planner_replay_queue_stats_for_tests, recycled_ops_capacities_for_tests};
use cranpose_core::{MemoryApplier, NodeId};
use cranpose_render_common::{
graph::RenderGraph,
software_text_raster::{
software_text_font_set_from_fonts_or_default, SoftwareTextFontSet, SoftwareTextMeasurer,
},
RenderScene, Renderer,
};
use cranpose_ui::{LayoutTree, TextMeasurer};
use cranpose_ui_graphics::{Rect, Size};
use frame_packet::RenderReturns;
use frontend::{DevOverlayCache, RendererFrontend};
use render::GpuRenderer;
use std::rc::Rc;
use std::sync::Arc;
pub(crate) fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
[
[rect.x, rect.y],
[rect.x + rect.width, rect.y],
[rect.x, rect.y + rect.height],
[rect.x + rect.width, rect.y + rect.height],
]
}
#[derive(Debug)]
pub enum WgpuRendererError {
Layout(String),
Wgpu(String),
}
#[derive(Debug, Clone)]
pub struct CapturedFrame {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DebugCpuAllocationStats {
pub scene_graph_node_count: usize,
pub scene_graph_heap_bytes: usize,
pub scene_hits_len: usize,
pub scene_hits_cap: usize,
pub scene_node_index_len: usize,
pub scene_node_index_cap: usize,
pub text_renderer_pool_len: usize,
pub text_renderer_pool_cap: usize,
pub swash_image_cache_len: usize,
pub swash_image_cache_cap: usize,
pub swash_outline_cache_len: usize,
pub swash_outline_cache_cap: usize,
pub image_texture_cache_len: usize,
pub image_texture_cache_cap: usize,
pub scratch_shape_data_cap: usize,
pub scratch_gradients_cap: usize,
pub scratch_image_vertices_cap: usize,
pub scratch_image_indices_cap: usize,
pub scratch_image_cmds_cap: usize,
pub scratch_segment_items_cap: usize,
pub scratch_effect_ranges_cap: usize,
pub scratch_layer_events_cap: usize,
pub staged_upload_bytes_cap: usize,
pub staged_upload_copies_cap: usize,
pub layer_surface_cache_len: usize,
pub layer_surface_cache_cap: usize,
pub layer_surface_cache_identity_len: usize,
pub layer_surface_cache_identity_cap: usize,
pub layer_surface_rect_cache_len: usize,
pub layer_surface_rect_cache_cap: usize,
pub layer_surface_requirements_cache_len: usize,
pub layer_surface_requirements_cache_cap: usize,
pub layer_cache_seen_this_frame_len: usize,
pub layer_cache_seen_this_frame_cap: usize,
}
pub(crate) struct TextSystemState {
measurer: SoftwareTextMeasurer,
}
impl TextSystemState {
fn from_font_set(fonts: SoftwareTextFontSet) -> Self {
Self {
measurer: SoftwareTextMeasurer::from_font_set(fonts, 8192),
}
}
pub(crate) fn text_cache_len(&self) -> usize {
0
}
}
impl pipeline::TextLayoutResolver for TextSystemState {
fn layout_text(
&mut self,
text: &cranpose_ui::text::AnnotatedString,
style: &cranpose_ui::text::TextStyle,
) -> cranpose_ui::text_layout_result::TextLayoutResult {
if cranpose_ui::has_current_app_context() {
cranpose_ui::text::layout_text(text, style)
} else {
self.measurer.layout(text, style)
}
}
}
#[derive(Clone)]
pub struct WgpuTextSystem {
software_fonts: SoftwareTextFontSet,
}
impl WgpuTextSystem {
pub fn from_fonts(fonts: &[&[u8]]) -> Self {
Self {
software_fonts: software_text_font_set_from_fonts_or_default(fonts),
}
}
pub fn from_font_set(software_fonts: SoftwareTextFontSet) -> Self {
Self { software_fonts }
}
pub(crate) fn render_state(&self) -> TextSystemState {
TextSystemState::from_font_set(self.software_fonts.clone())
}
pub(crate) fn software_fonts(&self) -> SoftwareTextFontSet {
self.software_fonts.clone()
}
}
pub fn headless_text_measurer() -> Rc<dyn TextMeasurer> {
headless_text_measurer_with_fonts(&[])
}
pub fn headless_text_measurer_with_fonts(fonts: &[&[u8]]) -> Rc<dyn TextMeasurer> {
Rc::new(SoftwareTextMeasurer::from_fonts_or_default(fonts, 8192))
}
pub struct WgpuRenderer {
frontend: RendererFrontend,
gpu_renderer: Option<GpuRenderer>,
renderer_epoch: u64,
surface_epoch: u64,
}
impl WgpuRenderer {
pub fn new(fonts: &[&[u8]]) -> Self {
Self::with_text_system(WgpuTextSystem::from_fonts(fonts))
}
pub fn with_font_set(fonts: SoftwareTextFontSet) -> Self {
Self::with_text_system(WgpuTextSystem::from_font_set(fonts))
}
pub fn with_text_system(text_system: WgpuTextSystem) -> Self {
Self {
frontend: RendererFrontend::new(
text_system.render_state(),
text_system.software_fonts(),
),
gpu_renderer: None,
renderer_epoch: 0,
surface_epoch: 0,
}
}
pub fn init_gpu(
&mut self,
device: Arc<wgpu::Device>,
queue: Arc<wgpu::Queue>,
surface_format: wgpu::TextureFormat,
adapter_backend: wgpu::Backend,
) {
#[cfg(not(target_arch = "wasm32"))]
if self.gpu_renderer.is_some() {
crate::shape_replay::SHAPE_REPLAY.with(|state| state.borrow_mut().renderer_replaced());
log::warn!(
"[command-feed] renderer replaced: retained slots retired, \
confirmations revoked, feed generation bumped"
);
}
self.renderer_epoch = self.renderer_epoch.wrapping_add(1);
#[cfg(not(target_arch = "wasm32"))]
let store_feed_generation = pipeline::retained_feed_generation();
#[cfg(target_arch = "wasm32")]
let store_feed_generation = 0;
self.gpu_renderer = Some(GpuRenderer::new(
device,
queue,
surface_format,
adapter_backend,
self.frontend.text_fonts.clone(),
self.renderer_epoch,
store_feed_generation,
));
}
pub fn note_surface_reconfigured(&mut self) {
self.surface_epoch = self.surface_epoch.wrapping_add(1);
}
pub fn set_root_scale(&mut self, scale: f32) {
self.frontend.root_scale = scale;
}
pub fn root_scale(&self) -> f32 {
self.frontend.root_scale
}
pub fn render(
&mut self,
view: &wgpu::TextureView,
width: u32,
height: u32,
) -> Result<(), WgpuRendererError> {
let Some(gpu_renderer) = self.gpu_renderer.as_mut() else {
return Err(WgpuRendererError::Wgpu(
"GPU renderer not initialized. Call init_gpu() first.".to_string(),
));
};
let packet = self
.frontend
.build_frame_packet(
width,
height,
gpu_renderer.replay_supported(),
self.renderer_epoch,
self.surface_epoch,
)
.ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
let frontend = &mut self.frontend;
let mut returns = RenderReturns::default();
let result = gpu_renderer.render(
view,
width,
height,
packet,
self.surface_epoch,
&mut returns,
);
if let Some(confirmations) = frontend.apply_returns(returns) {
gpu_renderer.restore_replay_ack_confirmations(confirmations);
}
result.map_err(WgpuRendererError::Wgpu)
}
pub fn capture_frame(
&mut self,
width: u32,
height: u32,
) -> Result<CapturedFrame, WgpuRendererError> {
self.capture_frame_with_scale(width, height, self.frontend.root_scale)
}
pub fn capture_frame_with_scale(
&mut self,
width: u32,
height: u32,
root_scale: f32,
) -> Result<CapturedFrame, WgpuRendererError> {
let Some(gpu_renderer) = self.gpu_renderer.as_mut() else {
return Err(WgpuRendererError::Wgpu(
"GPU renderer not initialized. Call init_gpu() first.".to_string(),
));
};
let packet = self
.frontend
.build_frame_packet_with_scale(
width,
height,
root_scale,
gpu_renderer.replay_supported(),
self.renderer_epoch,
self.surface_epoch,
)
.ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
let frontend = &mut self.frontend;
let mut returns = RenderReturns::default();
let result = gpu_renderer.render_to_rgba_pixels(
width,
height,
packet,
self.surface_epoch,
&mut returns,
);
if let Some(confirmations) = frontend.apply_returns(returns) {
gpu_renderer.restore_replay_ack_confirmations(confirmations);
}
let pixels = result.map_err(WgpuRendererError::Wgpu)?;
Ok(CapturedFrame {
width,
height,
pixels,
})
}
pub fn last_frame_stats(&self) -> Option<RenderStatsSnapshot> {
self.gpu_renderer
.as_ref()
.and_then(GpuRenderer::last_frame_stats)
}
pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
let mut stats = self
.gpu_renderer
.as_ref()
.map(GpuRenderer::debug_cpu_allocation_stats)
.unwrap_or_default();
stats.scene_graph_node_count = self
.frontend
.scene
.graph
.as_ref()
.map(RenderGraph::node_count)
.unwrap_or(0);
stats.scene_graph_heap_bytes = self
.frontend
.scene
.graph
.as_ref()
.map(RenderGraph::heap_bytes)
.unwrap_or(0);
stats.scene_hits_len = self.frontend.scene.hits.len();
stats.scene_hits_cap = self.frontend.scene.hits.capacity();
stats.scene_node_index_len = self.frontend.scene.node_index.len();
stats.scene_node_index_cap = self.frontend.scene.node_index.capacity();
stats.layer_surface_rect_cache_len += self.frontend.layer_surface_rect_cache.len();
stats.layer_surface_rect_cache_cap += self.frontend.layer_surface_rect_cache.capacity();
stats.layer_surface_requirements_cache_len +=
self.frontend.layer_surface_requirements_cache.len();
stats.layer_surface_requirements_cache_cap +=
self.frontend.layer_surface_requirements_cache.capacity();
stats
}
pub fn try_device(&self) -> Option<&wgpu::Device> {
self.gpu_renderer.as_ref().map(|r| &*r.device)
}
#[cfg(not(target_arch = "wasm32"))]
#[doc(hidden)]
pub fn instanced_quads_active(&self) -> bool {
self.gpu_renderer
.as_ref()
.is_some_and(GpuRenderer::instanced_quads_active)
}
#[cfg(not(target_arch = "wasm32"))]
#[doc(hidden)]
pub fn replay_slot_mesh_stats(&self) -> (usize, usize) {
self.gpu_renderer
.as_ref()
.map(GpuRenderer::replay_slot_mesh_stats)
.unwrap_or((0, 0))
}
#[cfg(not(target_arch = "wasm32"))]
#[doc(hidden)]
pub fn retained_bundle_stats(&self) -> (u64, u64) {
self.gpu_renderer
.as_ref()
.map(GpuRenderer::retained_bundle_stats)
.unwrap_or((0, 0))
}
#[cfg(not(target_arch = "wasm32"))]
#[doc(hidden)]
pub fn replay_generation_drops_for_tests(&self) -> u64 {
self.gpu_renderer
.as_ref()
.map(GpuRenderer::replay_generation_drops)
.unwrap_or(0)
}
#[cfg(not(target_arch = "wasm32"))]
#[doc(hidden)]
pub fn replay_ops_roundtrip_for_tests(&mut self, generation_skew: u64) -> usize {
self.gpu_renderer
.as_mut()
.expect("GPU renderer not initialized")
.replay_ops_roundtrip_for_tests(generation_skew)
}
#[doc(hidden)]
pub fn build_frame_packet_for_tests(
&mut self,
width: u32,
height: u32,
) -> Option<HeldFramePacket> {
let replay_supported = self
.gpu_renderer
.as_ref()
.is_some_and(GpuRenderer::replay_supported);
self.frontend
.build_frame_packet(
width,
height,
replay_supported,
self.renderer_epoch,
self.surface_epoch,
)
.map(HeldFramePacket)
}
#[doc(hidden)]
pub fn render_held_packet_for_tests(
&mut self,
view: &wgpu::TextureView,
width: u32,
height: u32,
packet: HeldFramePacket,
) -> Result<PresentOutcome, WgpuRendererError> {
let Some(gpu_renderer) = self.gpu_renderer.as_mut() else {
return Err(WgpuRendererError::Wgpu(
"GPU renderer not initialized. Call init_gpu() first.".to_string(),
));
};
let frontend = &mut self.frontend;
let mut returns = RenderReturns::default();
let result = gpu_renderer.render(
view,
width,
height,
packet.0,
self.surface_epoch,
&mut returns,
);
let outcome = returns.outcome;
if let Some(confirmations) = frontend.apply_returns(returns) {
gpu_renderer.restore_replay_ack_confirmations(confirmations);
}
result.map_err(WgpuRendererError::Wgpu)?;
Ok(outcome)
}
#[doc(hidden)]
pub fn has_retained_direct_scene_for_tests(&self) -> bool {
self.frontend.retained_direct_scene.is_some()
}
}
#[doc(hidden)]
pub struct HeldFramePacket(frame_packet::FramePacket);
impl Default for WgpuRenderer {
fn default() -> Self {
Self::new(&[])
}
}
impl Renderer for WgpuRenderer {
type Scene = Scene;
type Error = WgpuRendererError;
fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
app_context.set_text_measurer(SoftwareTextMeasurer::from_font_set(
self.frontend.text_fonts.clone(),
8192,
));
self.frontend.app_context = Some(app_context.downgrade());
}
fn scene(&self) -> &Self::Scene {
&self.frontend.scene
}
fn scene_mut(&mut self) -> &mut Self::Scene {
&mut self.frontend.scene
}
fn rebuild_scene(
&mut self,
layout_tree: &LayoutTree,
_viewport: Size,
) -> Result<(), Self::Error> {
self.frontend.scene.clear();
self.frontend.dev_overlay_graph = None;
self.frontend.dev_overlay_cache = None;
pipeline::render_layout_tree(layout_tree.root(), &mut self.frontend.scene);
Ok(())
}
fn rebuild_scene_from_applier(
&mut self,
applier: &mut MemoryApplier,
root: NodeId,
_viewport: Size,
) -> Result<(), Self::Error> {
self.frontend.scene.clear();
self.frontend.dev_overlay_graph = None;
self.frontend.dev_overlay_cache = None;
pipeline::render_from_applier(applier, root, &mut self.frontend.scene, 1.0);
Ok(())
}
fn update_scene_from_applier(
&mut self,
applier: &mut MemoryApplier,
root: NodeId,
viewport: Size,
dirty_nodes: &[NodeId],
) -> Result<(), Self::Error> {
if dirty_nodes.is_empty() {
return self.rebuild_scene_from_applier(applier, root, viewport);
}
pipeline::update_from_applier(
applier,
root,
&mut self.frontend.scene,
1.0,
dirty_nodes,
true,
);
Ok(())
}
fn update_visual_scene_from_applier(
&mut self,
applier: &mut MemoryApplier,
root: NodeId,
viewport: Size,
dirty_nodes: &[NodeId],
) -> Result<(), Self::Error> {
if dirty_nodes.is_empty() {
return self.rebuild_scene_from_applier(applier, root, viewport);
}
pipeline::update_from_applier(
applier,
root,
&mut self.frontend.scene,
1.0,
dirty_nodes,
false,
);
Ok(())
}
fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
const DEV_OVERLAY_NODE_ID: NodeId = NodeId::MAX;
let key = cranpose_render_common::dev_overlay::DevOverlayKey::new(text, viewport);
if self.frontend.dev_overlay_graph.is_some()
&& self
.frontend
.dev_overlay_cache
.as_ref()
.is_some_and(|cache| {
cache.text == key.text
&& cache.viewport_width_bits == key.viewport_width_bits
&& cache.viewport_height_bits == key.viewport_height_bits
})
{
return;
}
self.frontend.dev_overlay_graph = Some(
cranpose_render_common::dev_overlay::build_dev_overlay_graph(
text,
viewport,
DEV_OVERLAY_NODE_ID,
),
);
self.frontend.dev_overlay_cache = Some(DevOverlayCache {
text: key.text,
viewport_width_bits: key.viewport_width_bits,
viewport_height_bits: key.viewport_height_bits,
});
}
fn needs_frame_warmup(&self) -> bool {
self.gpu_renderer
.as_ref()
.is_some_and(GpuRenderer::needs_frame_warmup)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::TextLayoutResolver;
use cranpose_render_common::graph::RenderNode;
use cranpose_ui_graphics::GraphicsLayer;
use std::cell::Cell;
static TEST_FONT: &[u8] =
cranpose_render_common::software_text_raster::DEFAULT_SOFTWARE_TEXT_FONT_BYTES;
#[test]
fn dev_overlay_is_recorded_outside_app_graph() {
let mut renderer = WgpuRenderer::new(&[]);
renderer.draw_dev_overlay(
"240 FPS | avg 4.0ms | p95 4.5ms",
Size {
width: 800.0,
height: 600.0,
},
);
assert!(
renderer
.frontend
.scene
.graph
.as_ref()
.is_none_or(|graph| graph.root.children.iter().all(|child| {
!matches!(
child,
RenderNode::Layer(layer) if layer.node_id == Some(NodeId::MAX)
)
})),
"dev overlay must not be mixed into the app scene graph"
);
let graph = renderer
.frontend
.dev_overlay_graph
.as_ref()
.expect("overlay graph");
let Some(RenderNode::Layer(overlay)) = graph.root.children.last() else {
panic!("dev overlay should be the final top-level layer");
};
assert_eq!(overlay.node_id, Some(NodeId::MAX));
assert_eq!(
overlay.graphics_layer.compositing_strategy,
GraphicsLayer::default().compositing_strategy,
"dev overlay should not allocate an offscreen surface"
);
}
struct CountingTextMeasurer {
inner: SoftwareTextMeasurer,
layout_calls: Rc<Cell<usize>>,
}
impl CountingTextMeasurer {
fn new(layout_calls: Rc<Cell<usize>>) -> Self {
Self {
inner: SoftwareTextMeasurer::from_fonts_or_default(&[TEST_FONT], 16),
layout_calls,
}
}
}
impl TextMeasurer for CountingTextMeasurer {
fn measure(
&self,
text: &cranpose_ui::text::AnnotatedString,
style: &cranpose_ui::text::TextStyle,
) -> cranpose_ui::TextMetrics {
self.inner.measure(text, style)
}
fn get_offset_for_position(
&self,
text: &cranpose_ui::text::AnnotatedString,
style: &cranpose_ui::text::TextStyle,
x: f32,
y: f32,
) -> usize {
self.inner.get_offset_for_position(text, style, x, y)
}
fn get_cursor_x_for_offset(
&self,
text: &cranpose_ui::text::AnnotatedString,
style: &cranpose_ui::text::TextStyle,
offset: usize,
) -> f32 {
self.inner.get_cursor_x_for_offset(text, style, offset)
}
fn layout(
&self,
text: &cranpose_ui::text::AnnotatedString,
style: &cranpose_ui::text::TextStyle,
) -> cranpose_ui::text_layout_result::TextLayoutResult {
self.layout_calls.set(self.layout_calls.get() + 1);
self.inner.layout(text, style)
}
}
#[test]
fn headless_text_measurer_uses_software_text_font() {
let measurer = headless_text_measurer_with_fonts(&[TEST_FONT]);
let text = cranpose_ui::text::AnnotatedString::from("software text measurement");
let style = cranpose_ui::text::TextStyle::default();
let metrics = measurer.measure(&text, &style);
let layout = measurer.layout(&text, &style);
assert!(metrics.width > 0.0);
assert!(metrics.height > 0.0);
assert_eq!(layout.lines.len(), metrics.line_count);
}
#[test]
fn renderer_measurement_uses_software_text_service_without_render_cache_side_effect() {
let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
let app_context = cranpose_ui::AppContext::new();
renderer.attach_app_context_services(&app_context);
let metrics = app_context.enter(|| {
let text = cranpose_ui::text::AnnotatedString::from("phase local text cache");
let style = cranpose_ui::text::TextStyle {
span_style: cranpose_ui::text::SpanStyle {
font_size: cranpose_ui::text::TextUnit::Sp(14.0),
..Default::default()
},
paragraph_style: cranpose_ui::text::ParagraphStyle {
platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
include_font_padding: None,
shaping: Some(cranpose_ui::text::TextShaping::Basic),
}),
..Default::default()
},
};
cranpose_ui::text::measure_text(&text, &style)
});
assert!(
metrics.width > 0.0,
"software text service should measure text"
);
assert_eq!(
renderer.frontend.text_state.text_cache_len(),
0,
"WGPU must not keep a renderer-side shaping cache for measurement"
);
}
#[test]
fn renderer_attached_text_service_measures_long_multiline_text_with_software_line_height() {
let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
let app_context = cranpose_ui::AppContext::new();
renderer.attach_app_context_services(&app_context);
let prepared = app_context.enter(|| {
let text = cranpose_ui::text::AnnotatedString::from(
(0..48)
.map(|line| format!("// markdown code line {line:02}"))
.collect::<Vec<_>>()
.join("\n"),
);
let style = cranpose_ui::text::TextStyle::default();
cranpose_ui::text::prepare_text_layout(
&text,
&style,
cranpose_ui::text::TextLayoutOptions::default(),
Some(952.0),
)
});
assert_eq!(prepared.metrics.line_count, 48);
assert!(
prepared.metrics.line_height > 18.0,
"renderer-attached text service must not use fallback monospaced line height: {:?}",
prepared.metrics
);
assert!(
prepared.metrics.height > 900.0,
"48 software-measured lines should not collapse to a viewport-sized block: {:?}",
prepared.metrics
);
}
#[test]
fn render_text_layout_routes_through_attached_app_context_service() {
let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
let app_context = cranpose_ui::AppContext::new();
renderer.attach_app_context_services(&app_context);
let layout_calls = Rc::new(Cell::new(0));
app_context.set_text_measurer(CountingTextMeasurer::new(Rc::clone(&layout_calls)));
app_context.enter(|| {
let text = cranpose_ui::text::AnnotatedString::from("render text");
let style = cranpose_ui::text::TextStyle::default();
let layout = renderer.frontend.text_state.layout_text(&text, &style);
assert!(layout.width > 0.0);
});
assert_eq!(layout_calls.get(), 1);
}
}