| 1 | use super::*; |
| 2 | use crate::layout::TextEngine; |
| 3 | use draw::Renderer; |
| 4 | use onestore::page::Page; |
| 5 | use onestore::page::text::Paragraph; |
| 6 | use parley::fontique::Blob; |
| 7 | use std::{ |
| 8 | sync::Arc, |
| 9 | time::{Duration, Instant}, |
| 10 | }; |
| 11 | |
| 12 | #[test] |
| 13 | #[ignore = "manual release GPU timing and cache occupancy probe"] |
| 14 | fn renderer_cost() { |
| 15 | let mut engine = TextEngine::default(); |
| 16 | if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { |
| 17 | engine |
| 18 | .register_substitute(Blob::new(Arc::new(std::fs::read(font).unwrap()))) |
| 19 | .unwrap(); |
| 20 | } |
| 21 | let source = std::env::var_os("CANVAS_TEST_SECTION").map(|path| std::fs::read(path).unwrap()); |
| 22 | let title = std::env::var("CANVAS_TEST_PAGE").unwrap_or_default(); |
| 23 | let mut scene = source |
| 24 | .as_ref() |
| 25 | .map(|bytes| load_scene(bytes, &title, &mut engine)); |
| 26 | let layout = source.is_none().then(|| { |
| 27 | engine |
| 28 | .layout( |
| 29 | &Paragraph::new( |
| 30 | "A paragraph with café, trees 🌳 and a few words.\n".repeat(2000), |
| 31 | Default::default(), |
| 32 | ), |
| 33 | 360.0, |
| 34 | ) |
| 35 | .unwrap() |
| 36 | }); |
| 37 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); |
| 38 | let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); |
| 39 | eprintln!("canvas_gpu_adapter\t{:?}", adapter.get_info()); |
| 40 | let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap(); |
| 41 | let size = [1386, 759]; |
| 42 | let format = wgpu::TextureFormat::Rgba8UnormSrgb; |
| 43 | let target = device.create_texture(&wgpu::TextureDescriptor { |
| 44 | label: Some("Canvas profile target"), |
| 45 | size: wgpu::Extent3d { |
| 46 | width: size[0], |
| 47 | height: size[1], |
| 48 | depth_or_array_layers: 1, |
| 49 | }, |
| 50 | mip_level_count: 1, |
| 51 | sample_count: 1, |
| 52 | dimension: wgpu::TextureDimension::D2, |
| 53 | format, |
| 54 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT, |
| 55 | view_formats: &[], |
| 56 | }); |
| 57 | let view: draw::Target = target.create_view(&Default::default()).into(); |
| 58 | let mut renderer = Renderer::new(device, queue, format); |
| 59 | let occupancy = renderer.occupancy(); |
| 60 | eprintln!( |
| 61 | "canvas_gpu_allocated_bytes\tatlas\t{}\tvertex_buffer\t{}\ttarget\t{}", |
| 62 | occupancy.atlas_bytes, |
| 63 | occupancy.vertex_buffer_bytes, |
| 64 | u64::from(size[0]) * u64::from(size[1]) * 4 |
| 65 | ); |
| 66 | for phase in [ |
| 67 | "warm", |
| 68 | "pan", |
| 69 | "zoom", |
| 70 | "evict", |
| 71 | "offscreen_x", |
| 72 | "offscreen_y", |
| 73 | "reopen", |
| 74 | ] { |
| 75 | if phase == "reopen" && source.is_none() { |
| 76 | continue; |
| 77 | } |
| 78 | if phase.starts_with("offscreen") { |
| 79 | renderer.clear_glyph_cache(); |
| 80 | renderer.clear_images(); |
| 81 | } |
| 82 | for sample in 0..32 { |
| 83 | if phase == "evict" { |
| 84 | renderer.clear_glyph_cache(); |
| 85 | renderer.clear_images(); |
| 86 | } |
| 87 | let mut viewport = Viewport { |
| 88 | size, |
| 89 | scale: 96.0 / 72.0, |
| 90 | origin: [0.0; 2], |
| 91 | }; |
| 92 | match phase { |
| 93 | "pan" => viewport.origin[1] = -(sample as f32 * 63.25), |
| 94 | "zoom" => viewport.scale *= [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0][sample % 8], |
| 95 | "offscreen_x" => viewport.origin[0] = 1_000_000.0, |
| 96 | "offscreen_y" => viewport.origin[1] = 1_000_000.0, |
| 97 | "reopen" => { |
| 98 | scene = Some(load_scene(source.as_ref().unwrap(), &title, &mut engine)); |
| 99 | } |
| 100 | _ => {} |
| 101 | } |
| 102 | let mut primitives = Vec::new(); |
| 103 | if let Some(scene) = &scene { |
| 104 | scene |
| 105 | .append_primitives(&mut primitives, [0.0; 2], Paper::WHITE) |
| 106 | .unwrap(); |
| 107 | } else { |
| 108 | primitives.push(draw::Primitive::Text { |
| 109 | clip: None, |
| 110 | text: layout.as_ref().unwrap(), |
| 111 | origin: [36.0, 90.0], |
| 112 | ink: Paper::WHITE.ink, |
| 113 | }); |
| 114 | } |
| 115 | let start = Instant::now(); |
| 116 | renderer |
| 117 | .draw(&view, size, [1.0; 4], &[viewport.layer(&primitives)]) |
| 118 | .unwrap(); |
| 119 | let submitted = start.elapsed().as_nanos(); |
| 120 | renderer |
| 121 | .device() |
| 122 | .poll(wgpu::PollType::Wait { |
| 123 | submission_index: None, |
| 124 | timeout: Some(Duration::from_secs(10)), |
| 125 | }) |
| 126 | .unwrap(); |
| 127 | let completed = start.elapsed().as_nanos(); |
| 128 | let occupancy = renderer.occupancy(); |
| 129 | assert!(occupancy.within_budget); |
| 130 | eprintln!( |
| 131 | "canvas_gpu_sample\t{phase}\t{sample}\t{submitted}\t{completed}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", |
| 132 | occupancy.glyphs, |
| 133 | occupancy.atlas_texels, |
| 134 | occupancy.images, |
| 135 | occupancy.image_bytes, |
| 136 | occupancy.vertices, |
| 137 | occupancy.batches, |
| 138 | occupancy.vertex_capacity_bytes, |
| 139 | occupancy.batch_capacity_bytes, |
| 140 | occupancy.glyph_capacity, |
| 141 | occupancy.image_capacity |
| 142 | ); |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | fn load_scene(bytes: &[u8], title: &str, engine: &mut TextEngine) -> page::PageScene { |
| 148 | let store = onestore::Store::parse(bytes).unwrap(); |
| 149 | let index = onestore::RevisionIndex::parse(&store).unwrap(); |
| 150 | page::PageScene::new( |
| 151 | Page::from_document(&onestore::document::Document::parse(&index).unwrap(), title).unwrap(), |
| 152 | engine, |
| 153 | ) |
| 154 | .unwrap() |
| 155 | } |
| 156 | |
| 157 | /// Frame cost of a large page as the app draws it: the view's primitives and the renderer's |
| 158 | /// submission, resting, scrolling, typing and pressing Enter. `CANVAS_TEST_SECTION` and |
| 159 | /// `CANVAS_TEST_PAGE` choose a stored page; otherwise one outline of 4000 paragraphs, which then |
| 160 | /// gains a picture and a table in its middle to type beside and in. |
| 161 | #[cfg(feature = "interaction")] |
| 162 | #[test] |
| 163 | #[ignore = "manual release frame timing probe"] |
| 164 | fn frame_cost() { |
| 165 | use crate::document::{TextDocument, TextPosition}; |
| 166 | use crate::editor::CanvasEditor; |
| 167 | use crate::interaction::{PageView, TextColors}; |
| 168 | use draw::edit::{Key, NamedKey}; |
| 169 | use onestore::page::{ |
| 170 | Image, ParagraphContent, Table, TableCell, TableColumn, TableRow, text::new_id, |
| 171 | }; |
| 172 | const LINES: usize = 4000; |
| 173 | let lines = || { |
| 174 | (0..LINES).map(|line| { |
| 175 | Paragraph::new( |
| 176 | format!("Line {line}: a paragraph of notes with café and a few more words to wrap across the outline's width."), |
| 177 | Default::default(), |
| 178 | ) |
| 179 | }) |
| 180 | }; |
| 181 | let synthetic = std::env::var_os("CANVAS_TEST_SECTION").is_none(); |
| 182 | let mut engine = TextEngine::default(); |
| 183 | let (editor, scene) = match std::env::var_os("CANVAS_TEST_SECTION") { |
| 184 | Some(path) => { |
| 185 | let bytes = std::fs::read(path).unwrap(); |
| 186 | let store = onestore::Store::parse(&bytes).unwrap(); |
| 187 | let index = onestore::RevisionIndex::parse(&store).unwrap(); |
| 188 | let document = onestore::document::Document::parse(&index).unwrap(); |
| 189 | let title = std::env::var("CANVAS_TEST_PAGE").unwrap_or_default(); |
| 190 | let page = Page::from_document(&document, &title).unwrap(); |
| 191 | let (scene, editor) = page::PageScene::from_page(page, &mut engine).unwrap(); |
| 192 | (editor, Some((scene, [0.0; 2]))) |
| 193 | } |
| 194 | None => { |
| 195 | let document = TextDocument::new(lines().collect()).unwrap(); |
| 196 | let editor = CanvasEditor::new(&mut engine, document, 480.0).unwrap(); |
| 197 | (editor, None) |
| 198 | } |
| 199 | }; |
| 200 | let size = [2360, 1300]; |
| 201 | let mut view = PageView::new(editor, engine, scene, size, 2.0, Duration::from_millis(500)); |
| 202 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); |
| 203 | let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); |
| 204 | let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap(); |
| 205 | let format = wgpu::TextureFormat::Rgba8UnormSrgb; |
| 206 | let target = device.create_texture(&wgpu::TextureDescriptor { |
| 207 | label: Some("Frame profile target"), |
| 208 | size: wgpu::Extent3d { |
| 209 | width: size[0], |
| 210 | height: size[1], |
| 211 | depth_or_array_layers: 1, |
| 212 | }, |
| 213 | mip_level_count: 1, |
| 214 | sample_count: 1, |
| 215 | dimension: wgpu::TextureDimension::D2, |
| 216 | format, |
| 217 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT, |
| 218 | view_formats: &[], |
| 219 | }); |
| 220 | let target: draw::Target = target.create_view(&Default::default()).into(); |
| 221 | let mut renderer = Renderer::new(device, queue, format); |
| 222 | let colors = TextColors { |
| 223 | caret: [0.0, 0.0, 0.0, 1.0], |
| 224 | selection: [0.7, 0.8, 1.0, 1.0], |
| 225 | paper: Paper::WHITE, |
| 226 | }; |
| 227 | let _ = view.pointer_moved([400.0, 400.0]).unwrap(); |
| 228 | for phase in ["rest", "scroll", "type", "enter", "picture", "cell"] { |
| 229 | match phase { |
| 230 | "picture" if synthetic => { |
| 231 | let mut nodes = TextDocument::new(lines().collect()) |
| 232 | .unwrap() |
| 233 | .nodes() |
| 234 | .to_vec(); |
| 235 | let mut picture = nodes[0].clone(); |
| 236 | picture.id = new_id().unwrap(); |
| 237 | picture.content = ParagraphContent::Image(Image { |
| 238 | id: new_id().unwrap(), |
| 239 | layout: onestore::document::Layout { |
| 240 | max_width: Some(120.0), |
| 241 | max_height: Some(80.0), |
| 242 | ..Default::default() |
| 243 | }, |
| 244 | size: None, |
| 245 | bytes: None, |
| 246 | display: None, |
| 247 | alt: None, |
| 248 | background: false, |
| 249 | printout: None, |
| 250 | tags: Vec::new(), |
| 251 | link: None, |
| 252 | text: None, |
| 253 | }); |
| 254 | let cell = || TableCell { |
| 255 | id: new_id().unwrap(), |
| 256 | layout: Default::default(), |
| 257 | indents: vec![18.0, 0.0, 27.0], |
| 258 | shading: None, |
| 259 | paragraphs: vec![ |
| 260 | crate::document::node( |
| 261 | Paragraph::new("Cell".into(), Default::default()), |
| 262 | Default::default(), |
| 263 | ) |
| 264 | .unwrap(), |
| 265 | ], |
| 266 | unsupported: Vec::new(), |
| 267 | }; |
| 268 | let mut table = nodes[0].clone(); |
| 269 | table.id = new_id().unwrap(); |
| 270 | table.content = ParagraphContent::Table(Table { |
| 271 | id: new_id().unwrap(), |
| 272 | columns: vec![ |
| 273 | TableColumn { |
| 274 | width: 72.0, |
| 275 | locked: true, |
| 276 | }; |
| 277 | 2 |
| 278 | ], |
| 279 | rows: (0..2) |
| 280 | .map(|_| TableRow { |
| 281 | id: new_id().unwrap(), |
| 282 | cells: vec![cell(), cell()], |
| 283 | }) |
| 284 | .collect(), |
| 285 | borders: Some(true), |
| 286 | layout: Default::default(), |
| 287 | tags: Vec::new(), |
| 288 | }); |
| 289 | // The picture precedes paragraph LINES / 2, which the table follows. |
| 290 | nodes.insert(LINES / 2, picture); |
| 291 | nodes.insert(LINES / 2 + 2, table); |
| 292 | let document = TextDocument::from_nodes(nodes).unwrap(); |
| 293 | view.editor = CanvasEditor::new(&mut view.engine, document, 480.0).unwrap(); |
| 294 | } |
| 295 | "picture" | "cell" if !synthetic => continue, |
| 296 | _ => {} |
| 297 | } |
| 298 | let caret = match phase { |
| 299 | "picture" => Some(LINES / 2), |
| 300 | "cell" => Some(LINES / 2 + 1), |
| 301 | _ => None, |
| 302 | }; |
| 303 | if let Some(paragraph) = caret { |
| 304 | view.editor |
| 305 | .select( |
| 306 | [TextPosition { |
| 307 | paragraph, |
| 308 | offset: 0, |
| 309 | }; 2] |
| 310 | .into(), |
| 311 | ) |
| 312 | .unwrap(); |
| 313 | } |
| 314 | let mut times = [Vec::new(), Vec::new(), Vec::new()]; |
| 315 | for _ in 0..64 { |
| 316 | let start = Instant::now(); |
| 317 | match phase { |
| 318 | "scroll" => { |
| 319 | let _ = view.wheel([0.0, -120.0]).unwrap(); |
| 320 | } |
| 321 | "enter" => { |
| 322 | let _ = view.key(&Key::Named(NamedKey::Enter), None).unwrap(); |
| 323 | } |
| 324 | "type" | "picture" | "cell" => { |
| 325 | let _ = view.key(&Key::Named(NamedKey::Other), Some("a")).unwrap(); |
| 326 | } |
| 327 | _ => {} |
| 328 | } |
| 329 | let input = start.elapsed(); |
| 330 | let start = Instant::now(); |
| 331 | view.update_pictures(colors.paper, std::task::Waker::noop()); |
| 332 | let primitives = view.primitives(colors).unwrap(); |
| 333 | let built = start.elapsed(); |
| 334 | let start = Instant::now(); |
| 335 | renderer |
| 336 | .draw(&target, size, [1.0; 4], &[view.viewport.layer(&primitives)]) |
| 337 | .unwrap(); |
| 338 | let drawn = start.elapsed(); |
| 339 | drop(primitives); |
| 340 | renderer |
| 341 | .device() |
| 342 | .poll(wgpu::PollType::Wait { |
| 343 | submission_index: None, |
| 344 | timeout: Some(Duration::from_secs(10)), |
| 345 | }) |
| 346 | .unwrap(); |
| 347 | for (list, time) in times.iter_mut().zip([input, built, drawn]) { |
| 348 | list.push(time); |
| 349 | } |
| 350 | } |
| 351 | for (name, mut list) in ["input", "primitives", "draw"].into_iter().zip(times) { |
| 352 | list.sort(); |
| 353 | eprintln!( |
| 354 | "frame_cost\t{phase}\t{name}\tp50 {:.2} ms\tp99 {:.2} ms", |
| 355 | list[list.len() / 2].as_secs_f64() * 1e3, |
| 356 | list[list.len() * 99 / 100].as_secs_f64() * 1e3 |
| 357 | ); |
| 358 | } |
| 359 | } |
| 360 | } |