From 939081be8a62e17d4e2ec4abb68c052f8e3c506d Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 25 Sep 2026 11:46:41 -0700 Subject: [PATCH] refactor: move the renderer out of canvas into a draw crate Page and application chrome will paint through one renderer, atlas and image cache, while an iOS host embeds the canvas without the desktop UI kit. draw paints layers (transform, device clip, primitives); text arrives as glyph runs through the Glyphs trait, so canvas keeps its Windows-metric line boxes and the renderer never measures text. Tag artwork becomes a generic SVG icon primitive. canvas without the gpu feature stays free of wgpu for the fuzz targets. All 23 corpus pages render pixel-identical to the previous renderer. Assisted-by: claude-opus-5.5 --- Cargo.lock | 20 +- crates/canvas/Cargo.toml | 9 +- crates/canvas/examples/render_page.rs | 23 +- crates/canvas/src/gpu/mod.rs | 1716 ++-------------- crates/canvas/src/gpu/page.rs | 105 +- crates/canvas/src/gpu/profile.rs | 51 +- crates/canvas/src/interaction/mod.rs | 3 +- crates/canvas/src/interaction/scroll.rs | 3 +- crates/canvas/src/interaction/tests.rs | 22 +- crates/draw/Cargo.toml | 17 + .../gpu/canvas.wgsl => draw/src/draw.wgsl} | 0 .../src/gpu/tags.rs => draw/src/icon.rs} | 48 +- crates/draw/src/lib.rs | 1828 +++++++++++++++++ crates/draw/src/text.rs | 113 + crates/snowbound/Cargo.toml | 1 + crates/snowbound/src/main.rs | 8 +- tools/canvas/README.md | 9 +- 17 files changed, 2282 insertions(+), 1694 deletions(-) create mode 100644 crates/draw/Cargo.toml rename crates/{canvas/src/gpu/canvas.wgsl => draw/src/draw.wgsl} (100%) rename crates/{canvas/src/gpu/tags.rs => draw/src/icon.rs} (68%) create mode 100644 crates/draw/src/lib.rs create mode 100644 crates/draw/src/text.rs diff --git a/Cargo.lock b/Cargo.lock index 262651448567dc238141cdb83026bfa19baa2761..449b36a5e430582beed92793e8823a8c4f848cbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -395,17 +395,14 @@ version = "0.1.0" dependencies = [ "accesskit", "accesskit_consumer", - "bytemuck", - "image", + "draw", "onestore", "parley", "png", "pollster", - "roxmltree", "serde", "serde_json", "skrifa", - "swash", "wgpu", ] @@ -724,6 +721,20 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "draw" +version = "0.1.0" +dependencies = [ + "bytemuck", + "image", + "parley", + "png", + "pollster", + "roxmltree", + "swash", + "wgpu", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2632,6 +2643,7 @@ dependencies = [ "accesskit_winit", "arboard", "canvas", + "draw", "notebook", "objc2 0.5.2", "objc2-app-kit 0.2.2", diff --git a/crates/canvas/Cargo.toml b/crates/canvas/Cargo.toml index ba0af9be35a4de56cd96c287438b1e3f7ef6e861..a2190ec5b8b4bd21d3053504761a6d5ebe7a305a 100644 --- a/crates/canvas/Cargo.toml +++ b/crates/canvas/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" publish = false [features] -gpu = ["dep:bytemuck", "dep:swash", "dep:wgpu", "dep:image", "dep:roxmltree"] +gpu = ["dep:draw"] # How a page responds to input and presents itself to assistive technology. interaction = ["gpu", "dep:accesskit"] @@ -16,11 +16,7 @@ serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" skrifa = "0.44.0" -bytemuck = { version = "1.25", features = ["derive"], optional = true } -swash = { version = "0.2.10", optional = true } -wgpu = { workspace = true, optional = true } -image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"], optional = true } -roxmltree = { version = "0.21", optional = true } +draw = { path = "../draw", optional = true } accesskit = { version = "0.25.0", optional = true } [[bin]] @@ -35,6 +31,7 @@ path = "src/bin/page_probe.rs" accesskit_consumer = "0.39.0" pollster = "0.4" png = "0.18" +wgpu.workspace = true [[example]] name = "render_page" diff --git a/crates/canvas/examples/render_page.rs b/crates/canvas/examples/render_page.rs index 27117cf744c8fbbb757630d2ef22586939f7ff92..d563bcfc31ee37c779f7ab63fc5ecbd96190e702 100644 --- a/crates/canvas/examples/render_page.rs +++ b/crates/canvas/examples/render_page.rs @@ -1,5 +1,6 @@ -use canvas::gpu::{Renderer, Viewport, page::PageScene}; +use canvas::gpu::{Viewport, page::PageScene}; use canvas::layout::TextEngine; +use draw::Renderer; use onestore::page::Page; use onestore::{RevisionIndex, Store, document::Document}; use std::{env, fs, sync::Arc, time::Duration}; @@ -67,18 +68,20 @@ fn main() -> Result<(), Box> { mapped_at_creation: false, }); let mut renderer = Renderer::new(device, queue, format); + let viewport = Viewport { + size, + scale: 96.0 / 72.0, + origin: [ + (36.0 - margin_origin[0]) * (96.0 / 72.0), + (14.4 - margin_origin[1]) * (96.0 / 72.0) + origin_y, + ], + }; renderer .draw( &target.create_view(&Default::default()), - Viewport { - size, - scale: 96.0 / 72.0, - origin: [ - (36.0 - margin_origin[0]) * (96.0 / 72.0), - (14.4 - margin_origin[1]) * (96.0 / 72.0) + origin_y, - ], - }, - &primitives, + size, + [1.0; 4], + &[viewport.layer(&primitives)], ) .map_err(|e| format!("Page comparison rendering failed: {e:?}"))?; let mut encoder = renderer.device.create_command_encoder(&Default::default()); diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index 0ef75fa75f41b10e3a736db69564588420aabd62..ee91ab86bbbf5625aefdb3069c580e08faa124f7 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -1,150 +1,13 @@ pub mod page; #[cfg(test)] mod profile; -mod tags; use crate::{ - layout::TextLayout, - outline::{ParagraphTag, TagIcon}, + layout::{TextBrush, TextLayout}, + outline::TagIcon, }; -use bytemuck::{Pod, Zeroable}; -use image::ImageDecoder; -use parley::{PositionedLayoutItem, fontique::Blob}; -use std::{ - collections::{HashMap, HashSet}, - ops::Range, -}; -use swash::{ - FontRef, - scale::{Render, ScaleContext, Source, StrikeWith, image::Content}, - zeno::{Angle, Format, Transform, Vector}, -}; - -const ATLAS_SIZE: u32 = 2048; -const MAX_GLYPHS: usize = 8192; -const MAX_VERTICES: usize = 65_536; -const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024; -const MAX_IMAGES: usize = 256; - -#[derive(Clone)] -pub struct RasterImage { - size: [u32; 2], - pixels: Blob, -} - -impl RasterImage { - pub fn decode(encoded: &[u8]) -> Result { - if encoded.len() as u64 > MAX_IMAGE_BYTES { - return Err(RenderError::ImageBudget); - } - let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?; - let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format); - let mut limits = image::Limits::default(); - limits.max_image_width = Some(16_384); - limits.max_image_height = Some(16_384); - limits.max_alloc = Some(MAX_IMAGE_BYTES); - reader.limits(limits.clone()); - let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?; - let (width, height) = decoder.dimensions(); - if u64::from(width) * u64::from(height) * 4 > MAX_IMAGE_BYTES - || decoder.total_bytes() > MAX_IMAGE_BYTES - { - return Err(RenderError::ImageBudget); - } - limits - .reserve(decoder.total_bytes()) - .map_err(RenderError::ImageDecode)?; - decoder - .set_limits(limits) - .map_err(RenderError::ImageDecode)?; - let pixels = image::DynamicImage::from_decoder(decoder) - .map_err(RenderError::ImageDecode)? - .into_rgba8(); - Self::new([width, height], pixels.into_raw()) - } - - /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones. - pub fn new(size: [u32; 2], mut pixels: Vec) -> Result { - let bytes = u64::from(size[0]) - .checked_mul(u64::from(size[1])) - .and_then(|v| v.checked_mul(4)) - .ok_or(RenderError::InvalidImage)?; - if size.contains(&0) || bytes != pixels.len() as u64 { - return Err(RenderError::InvalidImage); - } - if bytes > MAX_IMAGE_BYTES { - return Err(RenderError::ImageBudget); - } - // Premultiply in linear light before filtering; transparent RGB must not bleed at edges. - for pixel in pixels.chunks_exact_mut(4) { - let alpha = pixel[3]; - if alpha == 255 { - continue; - } - let linear = colorref(u32::from_le_bytes(pixel.try_into().unwrap())); - for (byte, value) in pixel[..3].iter_mut().zip(linear) { - let value = value * f32::from(alpha) / 255.0; - *byte = srgb_byte(value); - } - } - Ok(Self { - size, - pixels: pixels.into(), - }) - } -} - -struct CachedImage { - binding: wgpu::BindGroup, - bytes: u64, -} - -struct Batch { - vertices: Range, - image: Option, - scissor: [u32; 4], -} - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct Vertex { - position: [f32; 2], - uv: [f32; 2], - color: [f32; 4], - local: [f32; 2], - shape: [f32; 4], - stroke: f32, -} - -#[derive(Hash, PartialEq, Eq)] -enum AtlasKey { - Text { - font: u64, - index: u32, - glyph: u16, - size: u32, - coords: Vec, - phase: [u8; 2], - embolden: bool, - skew: u32, - }, - Tag { - icon: TagIcon, - size: u32, - phase: [u8; 2], - }, -} - -#[derive(Clone, Copy)] -struct AtlasGlyph { - x: u32, - y: u32, - width: u32, - height: u32, - left: i32, - top: i32, - color: bool, -} +use draw::{GlyphRun, Glyphs, Layer, Primitive, RenderError}; +use parley::PositionedLayoutItem; #[derive(Clone, Copy)] pub struct Viewport { @@ -163,1492 +26,211 @@ impl Viewport { ] } - fn visible_image_rect(&self, rect: [f32; 4]) -> Result, RenderError> { - let rect = [ - rect[0] * self.scale + self.origin[0], - rect[1] * self.scale + self.origin[1], - rect[2] * self.scale + self.origin[0], - rect[3] * self.scale + self.origin[1], - ]; - if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] { - return Err(RenderError::InvalidPrimitive); + /// Document-point primitives drawn through this viewport. + pub fn layer<'a>(&self, primitives: &'a [Primitive<'a>]) -> Layer<'a> { + Layer { + scale: self.scale, + origin: self.origin, + clip: None, + primitives, } - Ok((rect[0] < self.size[0] as f32 - && rect[1] < self.size[1] as f32 - && rect[2] > 0.0 - && rect[3] > 0.0 - && rect[0] < rect[2] - && rect[1] < rect[3]) - .then_some(rect)) } } -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Stroke { - Solid(f32), - /// Dash and gap lengths are twice the stroke width. - Dashed(f32), -} - -/// Coordinates are document points; colors are linear RGBA. -pub enum Primitive<'a> { - Tag { - tag: &'a ParagraphTag, - origin: [f32; 2], - }, - Text { - layout: &'a TextLayout, - origin: [f32; 2], - /// Document-space paint bounds preserve the full shaping and caret advances. - clip: Option<[f32; 4]>, - }, - Rect { - rect: [f32; 4], - color: [f32; 4], - }, - RoundedRect { - rect: [f32; 4], - radius: [f32; 2], - /// None fills the shape; stroke widths are positive document points. - stroke: Option, - color: [f32; 4], - }, - Image { - image: &'a RasterImage, - rect: [f32; 4], - }, - /// A pen stroke between two points, `width` points across. - Segment { - from: [f32; 2], - to: [f32; 2], - width: f32, - /// A round pen tip caps the ends; otherwise they are square. - round: bool, - color: [f32; 4], - }, -} - -#[derive(Debug)] -pub enum RenderError { - AtlasFull, - FrameTooLarge, - UnreadableFont, - UnsupportedGlyph, - InvalidViewport, - InvalidPrimitive, - InvalidImage, - ImageBudget, - ImageTooLarge, - ImageDecode(image::ImageError), -} - -pub struct Renderer { - pub device: wgpu::Device, - pub queue: wgpu::Queue, - pipeline: wgpu::RenderPipeline, - image_pipeline: wgpu::RenderPipeline, - atlas: wgpu::Texture, - bind_group: wgpu::BindGroup, - image_sampler: wgpu::Sampler, - vertex_buffer: wgpu::Buffer, - vertices: Vec, - glyphs: HashMap>, - images: HashMap, - batches: Vec, - scaler: ScaleContext, - pen: [u32; 2], - row_height: u32, -} - -impl Renderer { - pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { - let shader = device.create_shader_module(wgpu::include_wgsl!("canvas.wgsl")); - let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Canvas texture"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("Canvas"), - bind_group_layouts: &[Some(&binding_layout)], - ..Default::default() - }); - let make_pipeline = |blend| { - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("Canvas"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vertex"), - compilation_options: Default::default(), - buffers: &[Some(wgpu::VertexBufferLayout { - array_stride: size_of::() as u64, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32], - })], - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fragment"), - compilation_options: Default::default(), - targets: &[Some(wgpu::ColorTargetState { - format, - blend: Some(blend), - write_mask: wgpu::ColorWrites::ALL, - })], - }), - primitive: Default::default(), - depth_stencil: None, - multisample: Default::default(), - multiview_mask: None, - cache: None, - }) - }; - let pipeline = make_pipeline(wgpu::BlendState::ALPHA_BLENDING); - let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); - let atlas = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Glyph atlas"), - size: wgpu::Extent3d { - width: ATLAS_SIZE, - height: ATLAS_SIZE, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - let view = atlas.create_view(&Default::default()); - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - mag_filter: wgpu::FilterMode::Nearest, - min_filter: wgpu::FilterMode::Nearest, - ..Default::default() - }); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Glyph atlas"), - layout: &pipeline.get_bind_group_layout(0), - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(&view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&sampler), - }, - ], - }); - let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Canvas vertices"), - size: (MAX_VERTICES * size_of::()) as u64, - usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("Canvas image filtering"), - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - ..Default::default() - }); - queue.write_texture( - atlas.as_image_copy(), - &[255; 4], - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4), - rows_per_image: Some(1), - }, - wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - ); - Self { - device, - queue, - pipeline, - image_pipeline, - atlas, - bind_group, - image_sampler, - vertex_buffer, - vertices: Vec::new(), - glyphs: HashMap::new(), - images: HashMap::new(), - batches: Vec::new(), - scaler: ScaleContext::with_max_entries(32), - pen: [1, 0], - row_height: 1, - } - } - - pub fn clear_glyph_cache(&mut self) { - self.glyphs.clear(); - self.pen = [1, 0]; - self.row_height = 1; - } - - pub fn draw( - &mut self, - target: &wgpu::TextureView, - viewport: Viewport, - primitives: &[Primitive<'_>], - ) -> Result<(), RenderError> { - if viewport.size.contains(&0) - || !viewport.scale.is_finite() - || viewport.scale <= 0.0 - || viewport.origin.iter().any(|v| !v.is_finite()) - { - return Err(RenderError::InvalidViewport); - } - let mut active_images = HashSet::new(); - let mut image_bytes = 0; - for primitive in primitives { - if let Primitive::Image { image, rect } = primitive - && viewport.visible_image_rect(*rect)?.is_some() - && active_images.insert(image.pixels.id()) - { - image_bytes += image.pixels.as_ref().len() as u64; - if image_bytes > MAX_IMAGE_BYTES || active_images.len() > MAX_IMAGES { - return Err(RenderError::ImageBudget); - } - } - } - for attempt in 0..2 { - self.vertices.clear(); - self.batches.clear(); - match self.prepare(viewport, primitives, &active_images) { - Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(), - result => { - result?; - break; - } - } - } - self.queue - .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); - let mut encoder = self.device.create_command_encoder(&Default::default()); - { - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Canvas"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: target, - depth_slice: None, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), - store: wgpu::StoreOp::Store, - }, - })], - ..Default::default() - }); - pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); - for batch in &self.batches { - let [x, y, width, height] = batch.scissor; - pass.set_scissor_rect(x, y, width, height); - pass.set_pipeline(if batch.image.is_some() { - &self.image_pipeline - } else { - &self.pipeline - }); - let binding = batch - .image - .map(|id| &self.images[&id].binding) - .unwrap_or(&self.bind_group); - pass.set_bind_group(0, binding, &[]); - pass.draw(batch.vertices.clone(), 0..1); - } - } - self.queue.submit([encoder.finish()]); - Ok(()) - } - - fn prepare( - &mut self, - viewport: Viewport, - primitives: &[Primitive<'_>], - active_images: &HashSet, +impl Glyphs for TextLayout { + fn runs( + &self, + paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, ) -> Result<(), RenderError> { - for primitive in primitives { - let start = self.vertices.len() as u32; - let mut image_id = None; - let mut scissor = [0, 0, viewport.size[0], viewport.size[1]]; - match primitive { - Primitive::Tag { tag, origin } => { - let origin = [ - viewport.origin[0] + (origin[0] + tag.origin[0]) * viewport.scale, - viewport.origin[1] + (origin[1] + tag.origin[1]) * viewport.scale, - ]; - self.tag(Viewport { origin, ..viewport }, tag)?; - } - Primitive::Text { - layout, - origin, - clip, - } => { - let origin = [ - viewport.origin[0] + origin[0] * viewport.scale, - viewport.origin[1] + origin[1] * viewport.scale, - ]; - if origin.iter().any(|v| !v.is_finite()) { - return Err(RenderError::InvalidPrimitive); - } - if let Some(clip) = clip { - let Some(rect) = viewport.visible_image_rect(*clip)? else { - continue; - }; - let left = rect[0].max(0.0).floor() as u32; - let top = rect[1].max(0.0).floor() as u32; - let right = rect[2].min(viewport.size[0] as f32).ceil() as u32; - let bottom = rect[3].min(viewport.size[1] as f32).ceil() as u32; - scissor = [left, top, right - left, bottom - top]; - } - self.text(Viewport { origin, ..viewport }, layout)?; - } - Primitive::Rect { rect, color } => { - self.quad( - viewport, - [ - rect[0] * viewport.scale + viewport.origin[0], - rect[1] * viewport.scale + viewport.origin[1], - rect[2] * viewport.scale + viewport.origin[0], - rect[3] * viewport.scale + viewport.origin[1], - ], - [0.5 / ATLAS_SIZE as f32; 4], - *color, - )?; - } - Primitive::RoundedRect { - rect, - radius, - stroke, - color, - } => { - if radius - .iter() - .any(|value| !value.is_finite() || *value < 0.0) - { - return Err(RenderError::InvalidPrimitive); - } - let width = match stroke { - None => 0.0, - Some(Stroke::Solid(width) | Stroke::Dashed(width)) => { - if !width.is_finite() || *width <= 0.0 || width * viewport.scale == 0.0 - { - return Err(RenderError::InvalidPrimitive); - } - *width - } - }; - let rect = [ - rect[0] * viewport.scale + viewport.origin[0], - rect[1] * viewport.scale + viewport.origin[1], - rect[2] * viewport.scale + viewport.origin[0], - rect[3] * viewport.scale + viewport.origin[1], - ]; - self.quad(viewport, rect, [0.5 / ATLAS_SIZE as f32; 4], *color)?; - let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; - if !(4.0 * (half[0] + half[1])).is_finite() { - return Err(RenderError::InvalidPrimitive); - } - let radius = if radius.contains(&0.0) { - [0.0; 2] - } else { - std::array::from_fn(|axis| (radius[axis] * viewport.scale).min(half[axis])) - }; - let width = (width * viewport.scale).min(half[0].min(half[1])); - // The stroke sign selects its dash pattern at the shader boundary. - let width = if matches!(stroke, Some(Stroke::Dashed(_))) { - -width - } else { - width - }; - for vertex in &mut self.vertices[start as usize..] { - vertex.shape = [half[0], half[1], radius[0], radius[1]]; - vertex.stroke = width; - } - } - Primitive::Segment { - from, - to, - width, - round, - color, - } => self.segment(viewport, *from, *to, *width, *round, *color)?, - Primitive::Image { image, rect } => { - if let Some(rect) = viewport.visible_image_rect(*rect)? { - self.image(image, active_images)?; - image_id = Some(image.pixels.id()); - self.quad(viewport, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?; - } - } - } - let end = self.vertices.len() as u32; - if start != end { - if let Some(last) = self.batches.last_mut() - && last.image == image_id - && last.scissor == scissor - { - last.vertices.end = end; - } else { - self.batches.push(Batch { - vertices: start..end, - image: image_id, - scissor, - }); - } - } - } - Ok(()) - } - - fn image(&mut self, image: &RasterImage, active: &HashSet) -> Result<(), RenderError> { - if self.images.contains_key(&image.pixels.id()) { - return Ok(()); - } - if image - .size - .iter() - .any(|v| *v > self.device.limits().max_texture_dimension_2d) - { - return Err(RenderError::ImageTooLarge); - } - let bytes = image.pixels.as_ref().len() as u64; - while self.images.values().map(|i| i.bytes).sum::() + bytes > MAX_IMAGE_BYTES - || self.images.len() >= MAX_IMAGES - { - let id = self - .images - .keys() - .find(|id| !active.contains(id)) - .copied() - .ok_or(RenderError::ImageBudget)?; - self.images.remove(&id); - } - let size = wgpu::Extent3d { - width: image.size[0], - height: image.size[1], - depth_or_array_layers: 1, - }; - let texture = self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("Canvas image"), - size, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - self.queue.write_texture( - texture.as_image_copy(), - image.pixels.as_ref(), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(image.size[0] * 4), - rows_per_image: Some(image.size[1]), - }, - size, - ); - let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Canvas image"), - layout: &self.pipeline.get_bind_group_layout(0), - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView( - &texture.create_view(&Default::default()), - ), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&self.image_sampler), - }, - ], - }); - self.images - .insert(image.pixels.id(), CachedImage { binding, bytes }); - Ok(()) - } - - fn text(&mut self, viewport: Viewport, layout: &TextLayout) -> Result<(), RenderError> { - let scale = viewport.scale; - let origin = viewport.origin; - for (line, line_box) in layout.lines() { - let top = line_box.top * scale + origin[1]; - if top + line_box.height * scale < 0.0 || top > viewport.size[1] as f32 { - continue; - } + for (line, bounds) in self.lines() { for item in line.items() { - let PositionedLayoutItem::GlyphRun(glyph_run) = item else { + let PositionedLayoutItem::GlyphRun(run) = item else { continue; }; - let run = glyph_run.run(); - let data = &run.font().font; - let size = run.font_size() * scale; - if !size.is_finite() || size > ATLAS_SIZE as f32 { - return Err(RenderError::AtlasFull); - } - let synthesis = run.synthesis(); - let coords: Vec = run - .normalized_coords() - .iter() - .map(|c| c.to_bits()) - .collect(); - let color = glyph_run.style().brush.color; - let rise = glyph_run.style().brush.rise; - for glyph in glyph_run.positioned_glyphs() { - let x = glyph.x * scale + origin[0]; - let y = (glyph.y + line_box.baseline - line.metrics().baseline - rise) * scale - + origin[1]; - // Quantization affects raster coverage only, never advances or line breaks. - let x = (x * 4.0).round() * 0.25; - let y = (y * 4.0).round() * 0.25; - let glyph_id = glyph - .id - .try_into() - .map_err(|_| RenderError::UnsupportedGlyph)?; - let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; - let key = AtlasKey::Text { - font: data.data.id(), - index: data.index, - glyph: glyph_id, - size: size.to_bits(), - coords: coords.clone(), - phase, - embolden: synthesis.embolden(), - skew: synthesis.skew().unwrap_or(0.0).to_bits(), - }; - let cached = if let Some(cached) = self.glyphs.get(&key) { - *cached - } else { - if self.glyphs.len() >= MAX_GLYPHS { - return Err(RenderError::AtlasFull); - } - let font = FontRef::from_index(data.data.as_ref(), data.index as usize) - .ok_or(RenderError::UnreadableFont)?; - let mut scaler = self - .scaler - .builder_with_id(font, [data.data.id(), u64::from(data.index)]) - .size(size) - .hint(false) - .normalized_coords(coords.iter().copied()) - .build(); - let mut render = Render::new(&[ - Source::ColorOutline(0), - Source::ColorBitmap(StrikeWith::BestFit), - Source::Outline, - ]); - // Swash rasterizes outlines in Y-up coordinates. - render.format(Format::Alpha).offset(Vector::new( - f32::from(phase[0]) * 0.25, - -f32::from(phase[1]) * 0.25, - )); - if synthesis.embolden() { - render.embolden(size / 24.0); - } - if let Some(skew) = synthesis.skew() { - render.transform(Some(Transform::skew( - Angle::from_degrees(skew), - Angle::from_degrees(0.0), - ))); - } - let image = render.render(&mut scaler, glyph_id); - let cached = if let Some(image) = - image.filter(|i| i.placement.width > 0 && i.placement.height > 0) - { - Some(self.upload(image)?) - } else { - None - }; - self.glyphs.insert(key, cached); - cached - }; - if let Some(glyph) = cached { - let mut left = x.floor() + glyph.left as f32; - let mut top = y.floor() - glyph.top as f32; - let mut width = glyph.width as f32; - let mut height = glyph.height as f32; - if glyph.color { - let line_top = line_box.top * scale + origin[1]; - let line_height = line_box.height * scale; - let ratio = (line_height / height).min(1.0); - left += width * (1.0 - ratio) * 0.5; - width *= ratio; - height *= ratio; - top = top - .clamp(line_top, (line_top + line_height - height).max(line_top)); - } - let atlas_size = ATLAS_SIZE as f32; - self.quad( - viewport, - [left, top, left + width, top + height], - [ - glyph.x as f32 / atlas_size, - glyph.y as f32 / atlas_size, - (glyph.x + glyph.width) as f32 / atlas_size, - (glyph.y + glyph.height) as f32 / atlas_size, - ], - if glyph.color { - [1.0; 4] - } else { - colorref(color) - }, - )?; - } - } - let metrics = run.font_metrics(); - for (decoration, offset, thickness) in [ - ( - &glyph_run.style().underline, - metrics.underline_offset, - metrics.underline_size, - ), - ( - &glyph_run.style().strikethrough, - metrics.strikethrough_offset, - metrics.strikethrough_size, - ), - ] { - if let Some(decoration) = decoration { - let x = glyph_run.offset() * scale + origin[0]; - let y = (line_box.baseline - rise - decoration.offset.unwrap_or(offset)) - * scale - + origin[1]; - self.quad( - viewport, - [ - x, - y, - x + glyph_run.advance() * scale, - y + (decoration.size.unwrap_or(thickness) * scale).max(1.0), - ], - [0.5 / ATLAS_SIZE as f32; 4], - colorref(decoration.brush.color), - )?; - } - } + draw::paint_parley_run( + &run, + bounds.baseline, + run.style().brush.rise, + [bounds.top, bounds.height], + |brush: &TextBrush| colorref(brush.color), + paint, + )?; } } Ok(()) } +} - fn upload(&mut self, image: swash::scale::image::Image) -> Result { - let p = image.placement; - if p.width + 2 > ATLAS_SIZE || p.height + 2 > ATLAS_SIZE { - return Err(RenderError::AtlasFull); - } - if self.pen[0] + p.width + 1 > ATLAS_SIZE { - self.pen[0] = 0; - self.pen[1] += self.row_height + 1; - self.row_height = 0; - } - if self.pen[1] + p.height + 1 > ATLAS_SIZE { - return Err(RenderError::AtlasFull); - } - let cached = AtlasGlyph { - x: self.pen[0], - y: self.pen[1], - width: p.width, - height: p.height, - left: p.left, - top: p.top, - color: image.content == Content::Color, - }; - let rgba = match image.content { - Content::Mask => image - .data - .iter() - .flat_map(|a| [255, 255, 255, *a]) - .collect(), - _ => image.data, - }; - self.queue.write_texture( - wgpu::TexelCopyTextureInfo { - origin: wgpu::Origin3d { - x: cached.x, - y: cached.y, - z: 0, - }, - ..self.atlas.as_image_copy() - }, - &rgba, - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(p.width * 4), - rows_per_image: Some(p.height), - }, - wgpu::Extent3d { - width: p.width, - height: p.height, - depth_or_array_layers: 1, - }, - ); - self.pen[0] += p.width + 1; - self.row_height = self.row_height.max(p.height); - Ok(cached) - } - - fn tag(&mut self, viewport: Viewport, tag: &ParagraphTag) -> Result<(), RenderError> { - let size = ParagraphTag::SIZE * viewport.scale; - let [x, y] = viewport.origin.map(|v| (v * 4.0).round() * 0.25); - if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) { - return Err(RenderError::InvalidPrimitive); - } - if x + size + 1.0 < 0.0 - || y + size + 1.0 < 0.0 - || x - 1.0 > viewport.size[0] as f32 - || y - 1.0 > viewport.size[1] as f32 - { - return Ok(()); - } - if size + 3.0 > ATLAS_SIZE as f32 { - return Err(RenderError::AtlasFull); - } - let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; - let key = AtlasKey::Tag { - icon: tag.icon, - size: size.to_bits(), - phase, - }; - let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) { - *glyph - } else { - if self.glyphs.len() >= MAX_GLYPHS { - return Err(RenderError::AtlasFull); - } - let glyph = self.upload(tags::rasterize(tag.icon, size, phase))?; - self.glyphs.insert(key, Some(glyph)); - glyph - }; - let left = x.floor(); - let top = y.floor(); - let atlas = ATLAS_SIZE as f32; - self.quad( - viewport, - [ - left, - top, - left + glyph.width as f32, - top + glyph.height as f32, - ], - [ - glyph.x as f32 / atlas, - glyph.y as f32 / atlas, - (glyph.x + glyph.width) as f32 / atlas, - (glyph.y + glyph.height) as f32 / atlas, - ], - [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }], - ) - } +/// The layout a text primitive paints. +#[cfg(test)] +pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout { + (text as &dyn std::any::Any) + .downcast_ref() + .expect("Page text is a TextLayout") +} - /// Draws the segment as a capsule in its own frame, reusing the rounded-rectangle distance. - fn segment( - &mut self, - viewport: Viewport, - from: [f32; 2], - to: [f32; 2], - width: f32, - round: bool, - color: [f32; 4], - ) -> Result<(), RenderError> { - let pixel = |p: [f32; 2]| { - [ - p[0] * viewport.scale + viewport.origin[0], - p[1] * viewport.scale + viewport.origin[1], - ] - }; - let [from, to] = [pixel(from), pixel(to)]; - // Hairlines stay one device pixel wide, as OneNote draws its thinnest pen. - let radius = (width * viewport.scale).max(1.0) * 0.5; - if from.iter().chain(&to).chain(&color).any(|v| !v.is_finite()) || !radius.is_finite() { - return Err(RenderError::InvalidPrimitive); - } - let pad = radius + 1.0; - if from[0].max(to[0]) + pad < 0.0 - || from[1].max(to[1]) + pad < 0.0 - || from[0].min(to[0]) - pad > viewport.size[0] as f32 - || from[1].min(to[1]) - pad > viewport.size[1] as f32 - { - return Ok(()); - } - if self.vertices.len() + 6 > MAX_VERTICES { - return Err(RenderError::FrameTooLarge); - } - let delta = [to[0] - from[0], to[1] - from[1]]; - let length = delta[0].hypot(delta[1]); - let along = if length > 0.0 { - [delta[0] / length, delta[1] / length] - } else { - [1.0, 0.0] - }; - let across = [-along[1], along[0]]; - let center = [(from[0] + to[0]) * 0.5, (from[1] + to[1]) * 0.5]; - let half = [length * 0.5 + radius, radius]; - let corner = if round { radius } else { 0.0 }; - let [hx, hy] = [half[0] + 1.0, half[1] + 1.0]; - for local in [ - [-hx, -hy], - [-hx, hy], - [hx, hy], - [-hx, -hy], - [hx, hy], - [hx, -hy], - ] { - let x = center[0] + along[0] * local[0] + across[0] * local[1]; - let y = center[1] + along[1] * local[0] + across[1] * local[1]; - self.vertices.push(Vertex { - position: [ - x * 2.0 / viewport.size[0] as f32 - 1.0, - 1.0 - y * 2.0 / viewport.size[1] as f32, - ], - uv: [0.5 / ATLAS_SIZE as f32; 2], - color, - local, - shape: [half[0], half[1], corner, corner], - stroke: 0.0, - }); - } - Ok(()) - } +const CHECKBOX: &str = include_str!("../../assets/tags/checkbox.svg"); +const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg"); +const QUESTION: &str = include_str!("../../assets/tags/question.svg"); +const MUSIC: &str = include_str!("../../assets/tags/music.svg"); +const EXCLAMATION: &str = include_str!("../../assets/tags/exclamation.svg"); +const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg"); +const YELLOW_SQUARE: &str = include_str!("../../assets/tags/yellow-square.svg"); +const BLUE_SQUARE: &str = include_str!("../../assets/tags/blue-square.svg"); - fn quad( - &mut self, - viewport: Viewport, - rect: [f32; 4], - uv: [f32; 4], - color: [f32; 4], - ) -> Result<(), RenderError> { - if rect.iter().chain(&color).any(|v| !v.is_finite()) - || rect[2] < rect[0] - || rect[3] < rect[1] - { - return Err(RenderError::InvalidPrimitive); - } - if rect[2] < 0.0 - || rect[3] < 0.0 - || rect[0] > viewport.size[0] as f32 - || rect[1] > viewport.size[1] as f32 - { - return Ok(()); - } - if self.vertices.len() + 6 > MAX_VERTICES { - return Err(RenderError::FrameTooLarge); - } - let x0 = rect[0] * 2.0 / viewport.size[0] as f32 - 1.0; - let x1 = rect[2] * 2.0 / viewport.size[0] as f32 - 1.0; - let y0 = 1.0 - rect[1] * 2.0 / viewport.size[1] as f32; - let y1 = 1.0 - rect[3] * 2.0 / viewport.size[1] as f32; - if [x0, x1, y0, y1].iter().any(|v| !v.is_finite()) { - return Err(RenderError::InvalidPrimitive); - } - let [hx, hy] = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; - for (position, uv, local) in [ - ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), - ([x0, y1], [uv[0], uv[3]], [-hx, hy]), - ([x1, y1], [uv[2], uv[3]], [hx, hy]), - ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), - ([x1, y1], [uv[2], uv[3]], [hx, hy]), - ([x1, y0], [uv[2], uv[1]], [hx, -hy]), - ] { - self.vertices.push(Vertex { - position, - uv, - color, - local, - shape: [0.0; 4], - stroke: 0.0, - }); - } - Ok(()) +/// The tag's artwork, drawn in order. +pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { + match icon { + TagIcon::CheckBox { checked: false } => &[CHECKBOX], + TagIcon::CheckBox { checked: true } => &[CHECKBOX, CHECKMARK], + TagIcon::Question => &[QUESTION], + TagIcon::Music => &[MUSIC], + TagIcon::Exclamation => &[EXCLAMATION], + TagIcon::RedSquare => &[RED_SQUARE], + TagIcon::YellowSquare => &[YELLOW_SQUARE], + TagIcon::BlueSquare => &[BLUE_SQUARE], } } -fn srgb_byte(value: f32) -> u8 { - let srgb = if value <= 0.0031308 { - value * 12.92 - } else { - 1.055 * value.powf(1.0 / 2.4) - 0.055 - }; - (srgb * 255.0).round() as u8 -} - +/// A COLORREF (0x00BBGGRR) as linear RGBA. pub fn colorref(color: u32) -> [f32; 4] { - let linear = |byte: u32| { - let value = (byte & 255) as f32 / 255.0; - if value <= 0.04045 { - value / 12.92 - } else { - ((value + 0.055) / 1.055).powf(2.4) - } - }; - [linear(color), linear(color >> 8), linear(color >> 16), 1.0] + let [red, green, blue, _] = color.to_le_bytes(); + draw::srgb(red, green, blue) } #[cfg(test)] mod tests { use super::*; use crate::layout::TextEngine; - use onestore::document::Format as TextFormat; + use onestore::document::Format; use onestore::page::text::Paragraph; use std::time::Duration; - #[test] - fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() { - let mut encoded = Vec::new(); - let mut encoder = png::Encoder::new(&mut encoded, 2, 1); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - let rgba = [255, 64, 0, 255, 0, 128, 255, 128]; - encoder - .write_header() - .unwrap() - .write_image_data(&rgba) - .unwrap(); - let decoded = RasterImage::decode(&encoded).unwrap(); - assert_eq!(decoded.size, [2, 1]); - assert_eq!(decoded.pixels.as_ref(), [255, 64, 0, 255, 0, 93, 188, 128]); - for end in 0..encoded.len() - 12 { - assert!(RasterImage::decode(&encoded[..end]).is_err(), "{end}"); - } - - let mut jpeg = Vec::new(); - image::codecs::jpeg::JpegEncoder::new(&mut jpeg) - .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8) - .unwrap(); - let decoded = RasterImage::decode(&jpeg).unwrap(); - assert_eq!(decoded.size, [4, 4]); - assert_eq!(decoded.pixels.as_ref(), [255; 4 * 4 * 4]); - - let mut gif = Vec::new(); - image::codecs::gif::GifEncoder::new(&mut gif) - .encode(&[255; 2 * 2 * 4], 2, 2, image::ExtendedColorType::Rgba8) - .unwrap(); - let decoded = RasterImage::decode(&gif).unwrap(); - assert_eq!(decoded.size, [2, 2]); - assert_eq!(decoded.pixels.as_ref(), [255; 2 * 2 * 4]); - - for size in [[8192, 8192], [16_385, 1]] { - let mut header = Vec::new(); - let mut encoder = png::Encoder::new(&mut header, size[0], size[1]); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - let mut writer = encoder.write_header().unwrap(); - writer.write_chunk(png::chunk::IDAT, &[]).unwrap(); - drop(writer); - let error = RasterImage::decode(&header).err().unwrap(); - if size[0] == 8192 { - assert!(matches!(error, RenderError::ImageBudget), "{error:?}"); - } else { - assert!( - matches!( - error, - RenderError::ImageDecode(image::ImageError::Limits(_)) - ), - "{error:?}" - ); - } - } - } - - #[test] - fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() { - assert!(matches!( - RasterImage::new([0, 1], Vec::new()), - Err(RenderError::InvalidImage) - )); - assert!(matches!( - RasterImage::new([2, 2], vec![255; 15]), - Err(RenderError::InvalidImage) - )); - assert!(matches!( - RasterImage::new([u32::MAX; 2], Vec::new()), - Err(RenderError::InvalidImage) - )); - let image = RasterImage::new([1, 1], vec![1, 2, 3, 255]).unwrap(); - assert_eq!(image.pixels.id(), image.clone().pixels.id()); - } - #[test] #[ignore = "requires a native GPU adapter"] - fn eviction_and_repaint_reproduce_pixels() { + fn tags_and_page_text_paint_through_the_renderer() { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap(); - let format = wgpu::TextureFormat::Rgba8UnormSrgb; + let size = [256, 64]; let target = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Canvas readback test"), + label: Some("Tag readback test"), size: wgpu::Extent3d { - width: 512, - height: 256, + width: size[0], + height: size[1], depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format, + format: wgpu::TextureFormat::Rgba8UnormSrgb, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let readback = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Canvas readback"), - size: 512 * 256 * 4, + label: Some("Tag readback"), + size: u64::from(size[0] * size[1] * 4), usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let mut renderer = Renderer::new(device, queue, format); - let mut engine = TextEngine::default(); - let layout = engine - .layout( - &Paragraph::new( - "Hello 🌳\nCafé e\u{301} שלום".into(), - TextFormat { - underline: Some(true), - ..TextFormat::default() - }, - ), - 220.0, - ) - .unwrap(); - let viewport = Viewport { - size: [512, 256], - scale: 2.0, - origin: [24.0; 2], - }; - let mut captures = Vec::new(); - let image = RasterImage::new( - [2, 2], - vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 0], - ) - .unwrap(); - let transparent_edge = - RasterImage::new([2, 1], vec![255, 0, 0, 255, 0, 0, 255, 0]).unwrap(); - let tags = [ + let mut renderer = draw::Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); + let icons = [ TagIcon::CheckBox { checked: false }, TagIcon::CheckBox { checked: true }, TagIcon::Question, TagIcon::Music, - ] - .map(|icon| ParagraphTag { - icon, + TagIcon::Exclamation, + TagIcon::RedSquare, + TagIcon::YellowSquare, + TagIcon::BlueSquare, + ]; + let text = TextEngine::default() + .layout( + &Paragraph::new( + "Tagged".into(), + Format { + color: Some(0x000000ff), + underline: Some(true), + ..Format::default() + }, + ), + 100.0, + ) + .unwrap(); + let mut primitives: Vec<_> = icons + .iter() + .enumerate() + .map(|(index, icon)| Primitive::Icon { + sources: tag_sources(*icon), + origin: [2.0 + 16.0 * index as f32, 2.0], + size: crate::outline::ParagraphTag::SIZE, + opacity: 1.0, + }) + .collect(); + primitives.push(Primitive::Text { + text: &text, + origin: [2.0, 20.0], + clip: None, + }); + let viewport = Viewport { + size, + scale: 2.0, origin: [0.0; 2], - label: String::new(), - disabled: false, - }); - let phase_layout = engine - .layout( - &Paragraph::new( - "H".into(), - TextFormat { - font: Some("Arial".into()), - font_size: Some(11.0), - ..TextFormat::default() - }, - ), - 20.0, + }; + renderer + .draw( + &target.create_view(&Default::default()), + size, + [1.0; 4], + &[viewport.layer(&primitives)], ) .unwrap(); - let mut primitives = vec![ - Primitive::Rect { - rect: [0.0, 0.0, 70.0, 14.0], - color: [0.55, 0.73, 1.0, 0.5], - }, - Primitive::Text { - clip: None, - layout: &layout, - origin: [0.0; 2], - }, - Primitive::Text { - clip: None, - layout: &layout, - origin: [8.0, 64.0], - }, - Primitive::Image { - image: &image, - rect: [128.0, 8.0, 160.0, 40.0], - }, - Primitive::Rect { - rect: [128.0, 8.0, 132.0, 12.0], - color: [0.0, 0.0, 1.0, 1.0], - }, - Primitive::Image { - image: &transparent_edge, - rect: [176.0, 8.0, 208.0, 40.0], - }, - Primitive::RoundedRect { - rect: [128.0, 48.0, 148.0, 68.0], - radius: [5.0, 10.0], - stroke: None, - color: [1.0, 0.0, 0.0, 1.0], - }, - Primitive::RoundedRect { - rect: [164.0, 48.0, 184.0, 68.0], - radius: [5.0; 2], - stroke: Some(Stroke::Solid(1.0)), - color: [0.0, 0.0, 1.0, 1.0], - }, - Primitive::RoundedRect { - rect: [200.0, 48.0, 240.0, 68.0], - radius: [3.0, 10.0], - stroke: Some(Stroke::Dashed(1.0)), - color: [0.0, 0.0, 1.0, 1.0], - }, - Primitive::Tag { - tag: &tags[0], - origin: [128.125, 80.25], - }, - Primitive::Tag { - tag: &tags[1], - origin: [148.125, 80.25], - }, - Primitive::Tag { - tag: &tags[2], - origin: [168.125, 80.25], - }, - Primitive::Tag { - tag: &tags[3], - origin: [188.125, 80.25], + let mut encoder = renderer.device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + target.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(size[0] * 4), + rows_per_image: Some(size[1]), + }, }, - Primitive::Segment { - from: [226.0, 104.0], - to: [240.0, 104.0], - width: 4.0, - round: true, - color: [1.0, 0.0, 0.0, 1.0], + wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, }, - ]; - primitives.extend((0..9).map(|step| Primitive::Text { - clip: None, - layout: &phase_layout, - origin: [8.0 + 24.0 * step as f32, 96.0 + 0.125 * step as f32], - })); - for pass in 0..6 { - if pass == 2 { - renderer.clear_glyph_cache(); - renderer.images.clear(); - } else if pass == 3 { - renderer = Renderer::new(renderer.device.clone(), renderer.queue.clone(), format); - } - let clipping = [ - Primitive::Text { - layout: &layout, - origin: [0.0; 2], - clip: (pass == 5).then_some([2.5, 1.25, 30.75, 20.5]), - }, - Primitive::Rect { - rect: [40.0, 40.0, 70.0, 50.0], - color: [0.0, 0.0, 1.0, 1.0], - }, - ]; - renderer - .draw( - &target.create_view(&Default::default()), - viewport, - if pass < 4 { &primitives } else { &clipping }, - ) - .unwrap(); - let mut encoder = renderer.device.create_command_encoder(&Default::default()); - encoder.copy_texture_to_buffer( - target.as_image_copy(), - wgpu::TexelCopyBufferInfo { - buffer: &readback, - layout: wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(512 * 4), - rows_per_image: Some(256), - }, - }, - wgpu::Extent3d { - width: 512, - height: 256, - depth_or_array_layers: 1, - }, - ); - renderer.queue.submit([encoder.finish()]); - let (sender, receiver) = std::sync::mpsc::channel(); - readback.map_async(wgpu::MapMode::Read, .., move |result| { - sender.send(result).unwrap(); - }); - renderer - .device - .poll(wgpu::PollType::Wait { - submission_index: None, - timeout: Some(Duration::from_secs(5)), - }) - .unwrap(); - receiver - .recv_timeout(Duration::from_secs(5)) - .unwrap() - .unwrap(); - captures.push(readback.get_mapped_range(..).unwrap().to_vec()); - readback.unmap(); - } - let centroids: Vec<_> = (0..9) - .map(|step| { - let mut weight = 0.0_f64; - let mut moment = 0.0_f64; - for y in 212..252 { - for x in (40 + 48 * step)..(72 + 48 * step) { - let value = captures[0][(y * 512 + x) * 4]; - let coverage = 1.0 - f64::from(colorref(u32::from(value))[0]); - weight += coverage; - moment += coverage * y as f64; - } - } - assert!(weight > 10.0, "phase sample {step} was not painted"); - moment / weight - }) - .collect(); - let pixel = |x: usize, y: usize| &captures[0][(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; - assert_eq!(pixel(490, 232), [255, 0, 0, 255]); - assert_eq!(pixel(473, 232), [255, 0, 0, 255]); - assert_eq!(pixel(490, 238), [255; 4]); - assert_eq!(pixel(472, 228), [255; 4]); - assert_eq!(pixel(300, 140), [255, 0, 0, 255]); - assert_eq!(pixel(280, 120), [255; 4]); - assert_eq!(pixel(282, 124), [255; 4]); - assert_eq!(pixel(281, 140), [255, 0, 0, 255]); - assert_eq!(pixel(372, 140), [255; 4]); - assert_eq!(pixel(372, 120), [0, 0, 255, 255]); - assert_eq!(pixel(352, 120), [255; 4]); - assert_eq!(pixel(424, 120), [255; 4]); - assert_eq!(pixel(464, 140), [255; 4]); - assert!((440..480).any(|x| pixel(x, 120)[0] < 40)); - assert!((440..480).any(|x| pixel(x, 120)[0] > 250)); - for x in 440..472 { - assert!(pixel(x, 120)[0].abs_diff(pixel(x + 8, 120)[0]) <= 1); - } - for (step, centroid) in centroids.iter().enumerate() { - let movement = centroid - centroids[0]; - assert!( - (movement - step as f64 * 0.25).abs() < 0.06, - "quarter-pixel step {step} moved the glyph by {movement} px" - ); - } - assert!(!renderer.glyphs.is_empty()); - assert!(renderer.glyphs.len() <= MAX_GLYPHS); - assert_eq!( - renderer - .glyphs - .keys() - .filter(|key| matches!(key, AtlasKey::Tag { .. })) - .count(), - 4 ); - for x in [280, 320, 360, 400] { - let colored = (184..210) - .flat_map(|y| (x..x + 26).map(move |x| (y * 512 + x) * 4)) - .filter(|offset| captures[0][*offset..*offset + 3].iter().any(|v| *v < 180)) + renderer.queue.submit([encoder.finish()]); + readback.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); + renderer + .device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(Duration::from_secs(5)), + }) + .unwrap(); + let pixels = readback.get_mapped_range(..).unwrap().to_vec(); + let pixel = |x: u32, y: u32| { + let offset = ((y * size[0] + x) * 4) as usize; + [pixels[offset], pixels[offset + 1], pixels[offset + 2]] + }; + for index in 0..icons.len() as u32 { + let painted = (4..28) + .flat_map(|y| (4 + 32 * index..28 + 32 * index).map(move |x| (x, y))) + .filter(|(x, y)| pixel(*x, *y).iter().any(|v| *v < 200)) .count(); - assert!(colored > 10, "tag at {x} was not painted"); + assert!(painted > 20, "tag {index} was not painted"); } - let count = renderer.glyphs.len(); - renderer - .draw( - &target.create_view(&Default::default()), - viewport, - &[Primitive::Tag { - tag: &tags[0], - origin: [10000.0; 2], - }], - ) - .unwrap(); - assert_eq!(renderer.glyphs.len(), count); - assert!( - captures[0] - .chunks_exact(4) - .filter(|pixel| pixel[0] < 200) - .count() - > 100 - ); - assert_eq!(captures[0], captures[1]); - assert_eq!(captures[0], captures[2]); - assert_eq!(captures[0], captures[3]); - assert_ne!(captures[4], captures[5]); - for y in 0..256 { - for x in 0..512 { - let offset = (y * 512 + x) * 4; - let expected = if ((29..86).contains(&x) && (26..65).contains(&y)) - || ((104..164).contains(&x) && (104..124).contains(&y)) - { - &captures[4][offset..offset + 4] - } else { - &[255; 4] - }; - assert_eq!( - &captures[5][offset..offset + 4], - expected, - "clipping at {x},{y}" - ); - } - } - for (x, y, color) in [ - (288, 48, [255, 0, 0, 255]), - (336, 96, [255; 4]), - (284, 44, [0, 0, 255, 255]), - ] { - let offset = (y * 512 + x) * 4; - assert_eq!(captures[0][offset..offset + 4], color); - } - let edge = &captures[0][(48 * 512 + 408) * 4..(48 * 512 + 408) * 4 + 4]; - assert_eq!(edge[0], 255, "{edge:?}"); - assert_eq!(edge[1], edge[2], "{edge:?}"); - assert!((180..=200).contains(&edge[1]), "{edge:?}"); - assert_eq!(renderer.images.len(), 2); - for primitive in [ - Primitive::RoundedRect { - rect: [-1e38, 0.0, 1e38, 1e38], - radius: [3.0; 2], - stroke: Some(Stroke::Dashed(1.0)), - color: [1.0; 4], - }, - Primitive::RoundedRect { - rect: [0.0, 0.0, 10.0, 10.0], - radius: [f32::NAN, 2.0], - stroke: None, - color: [1.0; 4], - }, - Primitive::RoundedRect { - rect: [0.0, 0.0, 10.0, 10.0], - radius: [2.0; 2], - stroke: Some(Stroke::Solid(0.0)), - color: [1.0; 4], - }, - Primitive::RoundedRect { - rect: [0.0, 0.0, 10.0, 10.0], - radius: [2.0; 2], - stroke: Some(Stroke::Dashed(f32::NAN)), - color: [1.0; 4], - }, - Primitive::Rect { - rect: [0.0, 0.0, f32::NAN, 1.0], - color: [1.0; 4], - }, - Primitive::Rect { - rect: [2.0, 0.0, 1.0, 1.0], - color: [1.0; 4], - }, - Primitive::Text { - clip: None, - layout: &layout, - origin: [f32::MAX; 2], - }, - Primitive::Text { - clip: Some([0.0, 0.0, f32::NAN, 1.0]), - layout: &layout, - origin: [0.0; 2], - }, - Primitive::Text { - clip: Some([2.0, 0.0, 1.0, 1.0]), - layout: &layout, - origin: [0.0; 2], - }, - ] { - assert!(matches!( - renderer.draw( - &target.create_view(&Default::default()), - viewport, - &[primitive] - ), - Err(RenderError::InvalidPrimitive) - )); - } - let too_many: Vec<_> = (0..=MAX_IMAGES) - .map(|_| RasterImage::new([1, 1], vec![255; 4]).unwrap()) - .collect(); - let primitives: Vec<_> = too_many - .iter() - .map(|image| Primitive::Image { - image, - rect: [0.0, 0.0, 1.0, 1.0], + let red = (40..64) + .flat_map(|y| (4..120).map(move |x| (x, y))) + .filter(|(x, y)| { + let [r, g, b] = pixel(*x, *y); + r > 200 && g < 80 && b < 80 }) - .collect(); - assert!(matches!( - renderer.draw( - &target.create_view(&Default::default()), - viewport, - &primitives - ), - Err(RenderError::ImageBudget) - )); - let primitives: Vec<_> = too_many - .iter() - .map(|image| Primitive::Image { - image, - rect: [-20.0, -20.0, -19.0, -19.0], - }) - .collect(); - renderer - .draw( - &target.create_view(&Default::default()), - viewport, - &primitives, - ) - .unwrap(); - assert_eq!(renderer.images.len(), 2); - let mut seen = HashSet::from([image.pixels.id(), transparent_edge.pixels.id()]); - let mut previous = None; - for _ in 0..5 { - let large = RasterImage::new([2048; 2], vec![255; 2048 * 2048 * 4]).unwrap(); - seen.insert(large.pixels.id()); - let mut primitives = vec![Primitive::Image { - image: &large, - rect: [0.0, 0.0, 16.0, 16.0], - }]; - if let Some(image) = &previous { - primitives.push(Primitive::Image { - image, - rect: [16.0, 0.0, 32.0, 16.0], - }); - } - renderer - .draw( - &target.create_view(&Default::default()), - viewport, - &primitives, - ) - .unwrap(); - assert!(renderer.images.contains_key(&large.pixels.id())); - if let Some(image) = &previous { - assert!(renderer.images.contains_key(&image.pixels.id())); - } - assert!(renderer.images.values().map(|i| i.bytes).sum::() <= MAX_IMAGE_BYTES); - previous = Some(large); - } - assert!(renderer.images.len() <= MAX_IMAGES); - assert!(seen.iter().any(|id| !renderer.images.contains_key(id))); - renderer.images.clear(); - renderer - .draw( - &target.create_view(&Default::default()), - viewport, - &[Primitive::Image { - image: &image, - rect: [128.0, 8.0, 160.0, 40.0], - }], - ) - .unwrap(); - assert_eq!(renderer.images.len(), 1); - assert!(renderer.images.contains_key(&image.pixels.id())); + .count(); + assert!(red > 50, "text colour or underline was not painted"); } } diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index d8827b62d7b085760065c7c3e55639425d3c54f4..8d0d5d5b1e61781c9abcca043b9654614c716d1a 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -1,4 +1,4 @@ -use super::{Primitive, RasterImage, RenderError, colorref}; +use super::colorref; use crate::editor::ReadOnlyObject; use crate::editor::page::Content; use crate::{ @@ -6,6 +6,7 @@ use crate::{ editor::{CanvasEditor, EditorError}, layout::{LayoutError, TextEngine}, }; +use draw::{Primitive, RasterImage, RenderError}; use onestore::page::Page; use std::fmt; @@ -197,8 +198,8 @@ impl PageScene { for (id, encoded) in payloads { let image = RasterImage::decode(encoded.ok_or(SceneError::MissingImage)?) .map_err(SceneError::Image)?; - bytes += image.pixels.as_ref().len() as u64; - if bytes > super::MAX_IMAGE_BYTES { + bytes += image.pixels().len() as u64; + if bytes > draw::MAX_IMAGE_BYTES { return Err(SceneError::Image(RenderError::ImageBudget)); } if images.insert(id, image).is_some() { @@ -253,7 +254,7 @@ impl PageScene { } if let Some(label) = object.label() { primitives.push(Primitive::Text { - layout: &label.text, + text: &label.text, origin: [origin[0] + label.origin[0], origin[1] + label.origin[1]], clip: None, }); @@ -508,7 +509,7 @@ impl PageScene { }); primitives.push(Primitive::Text { clip: None, - layout: &object.label, + text: &object.label, origin: [rect[0] + 8.0, rect[1] + 8.0], }); continue; @@ -632,7 +633,7 @@ impl crate::outline::OutlineLayout { primitives.push(match item { crate::math::MathItem::Text { layout, origin } => Primitive::Text { clip, - layout, + text: layout, origin: [x + origin[0], y + origin[1]], }, // As pen strokes, so hairline rules keep a device pixel. @@ -665,21 +666,26 @@ impl crate::outline::OutlineLayout { } None => primitives.push(Primitive::Text { clip, - layout: ¶graph.text, + text: ¶graph.text, origin: [x, y], }), } for (layout, marker) in ¶graph.markers { primitives.push(Primitive::Text { clip, - layout, + text: layout, origin: [origin[0] + marker[0], y + marker[1]], }); } for tag in ¶graph.tags { - primitives.push(Primitive::Tag { - tag, - origin: [origin[0] + self.tag_column_offset(), y], + primitives.push(Primitive::Icon { + sources: super::tag_sources(tag.icon), + origin: [ + origin[0] + self.tag_column_offset() + tag.origin[0], + y + tag.origin[1], + ], + size: crate::outline::ParagraphTag::SIZE, + opacity: if tag.disabled { 0.45 } else { 1.0 }, }); } } @@ -702,7 +708,7 @@ impl crate::outline::OutlineLayout { bounds[3] + origin[1] + 0.375, ], radius: [3.6; 2], - stroke: Some(super::Stroke::Solid(0.75)), + stroke: Some(draw::Stroke::Solid(0.75)), color, }); for cell in &table.cells { @@ -738,6 +744,7 @@ impl crate::outline::OutlineLayout { mod tests { use super::*; use crate::editor::TextOutline; + use crate::gpu::painted_layout; use onestore::page::text::Paragraph; use onestore::page::{Image, PageObject}; use onestore::{ExGuid, document::Layout}; @@ -1154,7 +1161,7 @@ mod tests { primitives .into_iter() .filter_map(|primitive| match primitive { - Primitive::Image { image, rect } => Some((image.pixels.id(), rect)), + Primitive::Image { image, rect } => Some((image.id(), rect)), _ => None, }) .collect::>() @@ -1555,7 +1562,7 @@ mod tests { for (_, paragraph) in outline.layouts() { primitives.push(Primitive::Text { clip: None, - layout: ¶graph.text, + text: ¶graph.text, origin: [ outline.origin()[0] + paragraph.origin[0] + offset[0], outline.origin()[1] + paragraph.origin[1] + offset[1], @@ -1647,19 +1654,31 @@ mod tests { ( Primitive::Text { clip: None, - layout: a, + text: a, origin: x, }, Primitive::Text { clip: None, - layout: b, + text: b, origin: y, }, ) => { assert_eq!(x, y); assert_eq!( - a.lines().next().unwrap().0.metrics().advance, - b.lines().next().unwrap().0.metrics().advance + painted_layout(*a) + .lines() + .next() + .unwrap() + .0 + .metrics() + .advance, + painted_layout(*b) + .lines() + .next() + .unwrap() + .0 + .metrics() + .advance ); } ( @@ -1667,7 +1686,7 @@ mod tests { Primitive::Image { image: b, rect: y }, ) => { assert_eq!(x, y); - assert_eq!(a.pixels.as_ref(), b.pixels.as_ref()); + assert_eq!(a.pixels(), b.pixels()); } _ => panic!("paint order changed"), } @@ -1675,39 +1694,63 @@ mod tests { let Primitive::Image { image, .. } = &actual[1] else { panic!() }; - image_id = image.pixels.id(); + image_id = image.id(); } let original = editor.active_outline().document().clone(); editor.insert(&mut engine, "new words ").unwrap(); { let actual = paint_editor(&scene, &editor); - let Primitive::Text { layout, .. } = &actual[0] else { + let Primitive::Text { text, .. } = &actual[0] else { panic!() }; - let Primitive::Text { layout: before, .. } = &reference_primitives[0] else { + let Primitive::Text { text: before, .. } = &reference_primitives[0] else { panic!() }; assert!( - layout.lines().next().unwrap().0.metrics().advance - > before.lines().next().unwrap().0.metrics().advance + painted_layout(*text) + .lines() + .next() + .unwrap() + .0 + .metrics() + .advance + > painted_layout(*before) + .lines() + .next() + .unwrap() + .0 + .metrics() + .advance ); let Primitive::Image { image, .. } = &actual[1] else { panic!() }; - assert_eq!(image.pixels.id(), image_id); + assert_eq!(image.id(), image_id); } editor.undo(&mut engine).unwrap(); assert_eq!(editor.active_outline().document(), &original); let actual = paint_editor(&scene, &editor); - let Primitive::Text { layout, .. } = &actual[0] else { + let Primitive::Text { text, .. } = &actual[0] else { panic!() }; - let Primitive::Text { layout: before, .. } = &reference_primitives[0] else { + let Primitive::Text { text: before, .. } = &reference_primitives[0] else { panic!() }; assert_eq!( - layout.lines().next().unwrap().0.metrics().advance, - before.lines().next().unwrap().0.metrics().advance + painted_layout(*text) + .lines() + .next() + .unwrap() + .0 + .metrics() + .advance, + painted_layout(*before) + .lines() + .next() + .unwrap() + .0 + .metrics() + .advance ); assert!(matches!( scene.append_primitives(&mut Vec::new(), [0.0; 2]), @@ -1846,8 +1889,8 @@ mod tests { }; assert_eq!(*a, [2.0, 5.0, 5.0, 9.0]); assert_eq!(*b, [12.0, -15.0, 15.0, -11.0]); - assert_eq!(first.pixels.id(), second.pixels.id()); - assert_eq!(first.pixels.as_ref(), [255, 0, 0, 255]); + assert_eq!(first.id(), second.id()); + assert_eq!(first.pixels(), [255, 0, 0, 255]); } #[test] diff --git a/crates/canvas/src/gpu/profile.rs b/crates/canvas/src/gpu/profile.rs index e94fd380920b8b2a095caf7a4858b3ac2de8fe0e..b16456e5b1c1764b5bea418ca1516b49dbdae8da 100644 --- a/crates/canvas/src/gpu/profile.rs +++ b/crates/canvas/src/gpu/profile.rs @@ -1,7 +1,9 @@ use super::*; use crate::layout::TextEngine; +use draw::Renderer; use onestore::page::Page; use onestore::page::text::Paragraph; +use parley::fontique::Blob; use std::{ sync::Arc, time::{Duration, Instant}, @@ -54,10 +56,11 @@ fn renderer_cost() { }); let view = target.create_view(&Default::default()); let mut renderer = Renderer::new(device, queue, format); + let occupancy = renderer.occupancy(); eprintln!( "canvas_gpu_allocated_bytes\tatlas\t{}\tvertex_buffer\t{}\ttarget\t{}", - u64::from(renderer.atlas.width()) * u64::from(renderer.atlas.height()) * 4, - renderer.vertex_buffer.size(), + occupancy.atlas_bytes, + occupancy.vertex_buffer_bytes, u64::from(size[0]) * u64::from(size[1]) * 4 ); for phase in [ @@ -74,12 +77,12 @@ fn renderer_cost() { } if phase.starts_with("offscreen") { renderer.clear_glyph_cache(); - renderer.images.clear(); + renderer.clear_images(); } for sample in 0..32 { if phase == "evict" { renderer.clear_glyph_cache(); - renderer.images.clear(); + renderer.clear_images(); } let mut viewport = Viewport { size, @@ -100,14 +103,16 @@ fn renderer_cost() { if let Some(scene) = &scene { scene.append_primitives(&mut primitives, [0.0; 2]).unwrap(); } else { - primitives.push(Primitive::Text { + primitives.push(draw::Primitive::Text { clip: None, - layout: layout.as_ref().unwrap(), + text: layout.as_ref().unwrap(), origin: [36.0, 90.0], }); } let start = Instant::now(); - renderer.draw(&view, viewport, &primitives).unwrap(); + renderer + .draw(&view, size, [1.0; 4], &[viewport.layer(&primitives)]) + .unwrap(); let submitted = start.elapsed().as_nanos(); renderer .device @@ -117,26 +122,20 @@ fn renderer_cost() { }) .unwrap(); let completed = start.elapsed().as_nanos(); - let image_bytes: u64 = renderer.images.values().map(|image| image.bytes).sum(); - let atlas_texels: u64 = renderer - .glyphs - .values() - .flatten() - .map(|glyph| u64::from(glyph.width) * u64::from(glyph.height)) - .sum(); - assert!(renderer.glyphs.len() <= MAX_GLYPHS); - assert!(renderer.images.len() <= MAX_IMAGES && image_bytes <= MAX_IMAGE_BYTES); - assert!(renderer.vertices.len() <= MAX_VERTICES); + let occupancy = renderer.occupancy(); + assert!(occupancy.within_budget); eprintln!( - "canvas_gpu_sample\t{phase}\t{sample}\t{submitted}\t{completed}\t{}\t{atlas_texels}\t{}\t{image_bytes}\t{}\t{}\t{}\t{}\t{}\t{}", - renderer.glyphs.len(), - renderer.images.len(), - renderer.vertices.len(), - renderer.batches.len(), - renderer.vertices.capacity() * size_of::(), - renderer.batches.capacity() * size_of::(), - renderer.glyphs.capacity(), - renderer.images.capacity() + "canvas_gpu_sample\t{phase}\t{sample}\t{submitted}\t{completed}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + occupancy.glyphs, + occupancy.atlas_texels, + occupancy.images, + occupancy.image_bytes, + occupancy.vertices, + occupancy.batches, + occupancy.vertex_capacity_bytes, + occupancy.batch_capacity_bytes, + occupancy.glyph_capacity, + occupancy.image_capacity ); } } diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 46eee5639591591655e2ecfd9f12c332f5c02373..cc6b7bfb3b856c011379d9521fd29535b6b18c91 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -9,7 +9,7 @@ mod scroll; #[cfg(test)] mod tests; -use crate::gpu::{Primitive, Stroke, Viewport, page::PageScene}; +use crate::gpu::{Viewport, page::PageScene}; use crate::{ date::DateField, editor::{ @@ -17,6 +17,7 @@ use crate::{ }, layout::TextEngine, }; +use draw::{Primitive, Stroke}; use std::{ error::Error, time::{Duration, Instant}, diff --git a/crates/canvas/src/interaction/scroll.rs b/crates/canvas/src/interaction/scroll.rs index b9bd93b74bb5b089f66d4a7934b6f4b5764b8cc4..0b398e8dfe9e877833738fe7daf580c7eaa3978f 100644 --- a/crates/canvas/src/interaction/scroll.rs +++ b/crates/canvas/src/interaction/scroll.rs @@ -1,4 +1,5 @@ -use crate::gpu::{Primitive, Viewport}; +use crate::gpu::Viewport; +use draw::Primitive; pub struct Scroll { pub min: [f32; 2], diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index 602286da71f71068afdef6845e5b15af7c363f1a..8d051299a5f486f942be54c62ea919cf5331345c 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::document::TextDocument; +use crate::gpu::painted_layout; use onestore::document::Format; use onestore::page::Page; use onestore::page::text::Paragraph; @@ -385,7 +386,12 @@ fn imported_page_widgets_and_accessibility_follow_edits() { .unwrap() .into_iter() .filter_map(|primitive| match primitive { - Primitive::Tag { tag, origin } => Some((tag.clone(), origin)), + Primitive::Icon { + sources, + origin, + opacity, + .. + } => Some(((sources, opacity), origin)), _ => None, }) .collect::>() @@ -427,9 +433,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() { let edited_tags = tags(&editor); assert_eq!(edited_tags.len(), original_tags.len()); for ((edited, _), (original, _)) in edited_tags.iter().zip(&original_tags) { - assert_eq!(edited.icon, original.icon); - assert_eq!(edited.label, original.label); - assert_eq!(edited.disabled, original.disabled); + assert_eq!(edited, original); } } editor.undo(&mut engine).unwrap(); @@ -891,14 +895,14 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() { .unwrap(); let color = crate::gpu::colorref(0x00a3a3a3); assert!(matches!(primitives[0], Primitive::RoundedRect { - stroke: Some(crate::gpu::Stroke::Solid(0.75)), color: actual, .. + stroke: Some(draw::Stroke::Solid(0.75)), color: actual, .. } if actual == color)); assert!(matches!(primitives[1], Primitive::Rect { color: actual, .. } if actual == color)); assert!(matches!(primitives[2], Primitive::Rect { color, .. } if color == COLORS.selection)); let painted = primitives .iter() .filter_map(|primitive| match primitive { - Primitive::Text { layout, origin, .. } => Some((layout.id(), *origin)), + Primitive::Text { text, origin, .. } => Some((painted_layout(*text).id(), *origin)), _ => None, }) .collect::>(); @@ -948,7 +952,7 @@ fn resize_preview_paints_reflowed_text_without_changing_the_editor() { let painted = primitives .iter() .find_map(|primitive| match primitive { - Primitive::Text { layout, .. } => Some(*layout), + Primitive::Text { text, .. } => Some(painted_layout(*text)), _ => None, }) .unwrap(); @@ -1161,7 +1165,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { .unwrap() .into_iter() .filter_map(|p| match p { - Primitive::Text { layout, .. } => Some(layout.id()), + Primitive::Text { text, .. } => Some(painted_layout(text).id()), _ => None, }) .collect(); @@ -1213,7 +1217,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { .unwrap() .into_iter() .filter_map(|p| match p { - Primitive::Text { layout, .. } => Some(layout.id()), + Primitive::Text { text, .. } => Some(painted_layout(text).id()), _ => None, }) .collect(); diff --git a/crates/draw/Cargo.toml b/crates/draw/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..ca40daaa10f05183248e549908e0f2708cf47b10 --- /dev/null +++ b/crates/draw/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "draw" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +bytemuck = { version = "1.25", features = ["derive"] } +image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"] } +parley.workspace = true +roxmltree = "0.21" +swash = "0.2.10" +wgpu.workspace = true + +[dev-dependencies] +pollster = "0.4" +png = "0.18" diff --git a/crates/canvas/src/gpu/canvas.wgsl b/crates/draw/src/draw.wgsl similarity index 100% rename from crates/canvas/src/gpu/canvas.wgsl rename to crates/draw/src/draw.wgsl diff --git a/crates/canvas/src/gpu/tags.rs b/crates/draw/src/icon.rs similarity index 68% rename from crates/canvas/src/gpu/tags.rs rename to crates/draw/src/icon.rs index 37038f6904e233460ecb9bfb7e8ae0793f7b90b9..f6d9fd43332c8894b5c4f3a92336ee5c12e24019 100644 --- a/crates/canvas/src/gpu/tags.rs +++ b/crates/draw/src/icon.rs @@ -1,26 +1,15 @@ -use crate::outline::TagIcon; use swash::{ scale::image::{Content, Image}, zeno::{Mask, Placement, Transform, Vector}, }; -pub(crate) fn rasterize(icon: TagIcon, size: f32, phase: [u8; 2]) -> Image { - let (source, overlay) = match icon { - TagIcon::CheckBox { checked } => ( - include_str!("../../assets/tags/checkbox.svg"), - checked.then_some(include_str!("../../assets/tags/checkmark.svg")), - ), - TagIcon::Question => (include_str!("../../assets/tags/question.svg"), None), - TagIcon::Music => (include_str!("../../assets/tags/music.svg"), None), - TagIcon::Exclamation => (include_str!("../../assets/tags/exclamation.svg"), None), - TagIcon::RedSquare => (include_str!("../../assets/tags/red-square.svg"), None), - TagIcon::YellowSquare => (include_str!("../../assets/tags/yellow-square.svg"), None), - TagIcon::BlueSquare => (include_str!("../../assets/tags/blue-square.svg"), None), - }; +/// Rasterizes 16×16 SVG sources over each other at `size` device pixels, offset by +/// `phase` quarter pixels. Paths are filled with two-stop vertical gradients. +pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image { let width = (size + 1.0).ceil() as u32; let mut pixels = vec![[0.0_f32; 4]; (width * width) as usize]; - for source in std::iter::once(source).chain(overlay) { - let svg = roxmltree::Document::parse(source).expect("Bundled tag SVG must be valid"); + for source in sources { + let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid"); assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16")); for path in svg.descendants().filter(|node| node.has_tag_name("path")) { let gradient = path @@ -47,7 +36,7 @@ pub(crate) fn rasterize(icon: TagIcon, size: f32, phase: [u8; 2]) -> Image { ) .unwrap(); let [_, r, g, b] = color.to_be_bytes(); - super::colorref(u32::from_le_bytes([r, g, b, 0])) + super::srgb(r, g, b) }); let top = stops.next().unwrap(); let bottom = stops.next().unwrap(); @@ -101,22 +90,16 @@ pub(crate) fn rasterize(icon: TagIcon, size: f32, phase: [u8; 2]) -> Image { mod tests { use super::*; + const BOX: &str = r##""##; + const MARK: &str = r##""##; + #[test] - fn tag_masks_keep_transparency_and_checked_state_across_scales() { + fn icons_keep_transparency_and_overlays_across_scales() { for size in [8.0, 16.0, 32.0, 57.5] { for phase in [[0, 0], [1, 2], [3, 3]] { - let mut masks = Vec::new(); - for icon in [ - TagIcon::CheckBox { checked: false }, - TagIcon::CheckBox { checked: true }, - TagIcon::Question, - TagIcon::Music, - TagIcon::Exclamation, - TagIcon::RedSquare, - TagIcon::YellowSquare, - TagIcon::BlueSquare, - ] { - let image = rasterize(icon, size, phase); + let plain = rasterize(&[BOX], size, phase); + let marked = rasterize(&[BOX, MARK], size, phase); + for image in [&plain, &marked] { assert_eq!( image.data.len(), (image.placement.width * image.placement.height * 4) as usize @@ -130,10 +113,9 @@ mod tests { .filter(|p| p[3] == 0) .all(|p| p[..3] == [0; 3]) ); - masks.push(image.data); } - assert_ne!(masks[0], masks[1]); - assert!(masks[1].chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0)); + assert_ne!(plain.data, marked.data); + assert!(marked.data.chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0)); } } } diff --git a/crates/draw/src/lib.rs b/crates/draw/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..3a15b65c3b6ad1d105b6ef0bdbe9a52dc60b0e62 --- /dev/null +++ b/crates/draw/src/lib.rs @@ -0,0 +1,1828 @@ +//! Paints layers of primitives with wgpu: glyphs and icons through one atlas, images, +//! rounded rectangles and pen strokes. Each layer has its own transform and clip. + +mod icon; +mod text; + +pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; + +use bytemuck::{Pod, Zeroable}; +use image::ImageDecoder; +use parley::fontique::Blob; +use std::{ + collections::{HashMap, HashSet}, + ops::Range, +}; +use swash::{ + FontRef, + scale::{Render, ScaleContext, Source, StrikeWith, image::Content}, + zeno::{Angle, Format, Transform, Vector}, +}; + +const ATLAS_SIZE: u32 = 2048; +const MAX_GLYPHS: usize = 8192; +const MAX_VERTICES: usize = 65_536; +/// Decoded bytes of all images one frame may paint. +pub const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_IMAGES: usize = 256; + +#[derive(Clone)] +pub struct RasterImage { + size: [u32; 2], + pixels: Blob, +} + +impl RasterImage { + pub fn decode(encoded: &[u8]) -> Result { + if encoded.len() as u64 > MAX_IMAGE_BYTES { + return Err(RenderError::ImageBudget); + } + let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?; + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(16_384); + limits.max_image_height = Some(16_384); + limits.max_alloc = Some(MAX_IMAGE_BYTES); + reader.limits(limits.clone()); + let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?; + let (width, height) = decoder.dimensions(); + if u64::from(width) * u64::from(height) * 4 > MAX_IMAGE_BYTES + || decoder.total_bytes() > MAX_IMAGE_BYTES + { + return Err(RenderError::ImageBudget); + } + limits + .reserve(decoder.total_bytes()) + .map_err(RenderError::ImageDecode)?; + decoder + .set_limits(limits) + .map_err(RenderError::ImageDecode)?; + let pixels = image::DynamicImage::from_decoder(decoder) + .map_err(RenderError::ImageDecode)? + .into_rgba8(); + Self::new([width, height], pixels.into_raw()) + } + + /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones. + pub fn new(size: [u32; 2], mut pixels: Vec) -> Result { + let bytes = u64::from(size[0]) + .checked_mul(u64::from(size[1])) + .and_then(|v| v.checked_mul(4)) + .ok_or(RenderError::InvalidImage)?; + if size.contains(&0) || bytes != pixels.len() as u64 { + return Err(RenderError::InvalidImage); + } + if bytes > MAX_IMAGE_BYTES { + return Err(RenderError::ImageBudget); + } + // Premultiply in linear light before filtering; transparent RGB must not bleed at edges. + for pixel in pixels.chunks_exact_mut(4) { + let alpha = pixel[3]; + if alpha == 255 { + continue; + } + let linear = srgb(pixel[0], pixel[1], pixel[2]); + for (byte, value) in pixel[..3].iter_mut().zip(linear) { + let value = value * f32::from(alpha) / 255.0; + *byte = srgb_byte(value); + } + } + Ok(Self { + size, + pixels: pixels.into(), + }) + } + + /// The identity caches and uploads share across clones. + pub fn id(&self) -> u64 { + self.pixels.id() + } + + /// Premultiplied sRGB RGBA rows. + pub fn pixels(&self) -> &[u8] { + self.pixels.as_ref() + } +} + +struct CachedImage { + binding: wgpu::BindGroup, + bytes: u64, +} + +struct Batch { + vertices: Range, + image: Option, + scissor: [u32; 4], +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct Vertex { + position: [f32; 2], + uv: [f32; 2], + color: [f32; 4], + local: [f32; 2], + shape: [f32; 4], + stroke: f32, +} + +#[derive(Hash, PartialEq, Eq)] +enum AtlasKey { + Text { + font: u64, + index: u32, + glyph: u16, + size: u32, + coords: Vec, + phase: [u8; 2], + embolden: bool, + skew: u32, + }, + Icon { + sources: &'static [&'static str], + size: u32, + phase: [u8; 2], + }, +} + +#[derive(Clone, Copy)] +struct AtlasGlyph { + x: u32, + y: u32, + width: u32, + height: u32, + left: i32, + top: i32, + color: bool, +} + +/// Primitives sharing one transform and clip. +pub struct Layer<'a> { + /// Device pixels per layer unit. + pub scale: f32, + /// Device position of the layer origin. + pub origin: [f32; 2], + /// Device bounds `[left, top, right, bottom]` the layer paints within. + pub clip: Option<[f32; 4]>, + pub primitives: &'a [Primitive<'a>], +} + +/// A layer's transform onto the target. +#[derive(Clone, Copy)] +struct Space { + size: [u32; 2], + scale: f32, + origin: [f32; 2], +} + +impl Space { + fn visible_rect(&self, rect: [f32; 4]) -> Result, RenderError> { + let rect = [ + rect[0] * self.scale + self.origin[0], + rect[1] * self.scale + self.origin[1], + rect[2] * self.scale + self.origin[0], + rect[3] * self.scale + self.origin[1], + ]; + if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] { + return Err(RenderError::InvalidPrimitive); + } + Ok((rect[0] < self.size[0] as f32 + && rect[1] < self.size[1] as f32 + && rect[2] > 0.0 + && rect[3] > 0.0 + && rect[0] < rect[2] + && rect[1] < rect[3]) + .then_some(rect)) + } + + /// Whole device pixels covering `rect` within the target, as a scissor rectangle. + fn scissor(&self, rect: [f32; 4]) -> [u32; 4] { + let left = rect[0].max(0.0).floor() as u32; + let top = rect[1].max(0.0).floor() as u32; + let right = (rect[2].min(self.size[0] as f32).ceil() as u32).max(left); + let bottom = (rect[3].min(self.size[1] as f32).ceil() as u32).max(top); + [left, top, right - left, bottom - top] + } +} + +fn intersect(a: [u32; 4], b: [u32; 4]) -> [u32; 4] { + let left = a[0].max(b[0]); + let top = a[1].max(b[1]); + let right = (a[0] + a[2]).min(b[0] + b[2]).max(left); + let bottom = (a[1] + a[3]).min(b[1] + b[3]).max(top); + [left, top, right - left, bottom - top] +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Stroke { + Solid(f32), + /// Dash and gap lengths are twice the stroke width. + Dashed(f32), +} + +/// Coordinates are layer units; colors are linear RGBA. +pub enum Primitive<'a> { + Text { + text: &'a dyn Glyphs, + origin: [f32; 2], + /// Layer bounds of the painted glyphs, which keep their full shaping and advances. + clip: Option<[f32; 4]>, + }, + /// 16×16 SVG artwork painted `size` units square, later sources over earlier ones. + Icon { + sources: &'static [&'static str], + origin: [f32; 2], + size: f32, + opacity: f32, + }, + Rect { + rect: [f32; 4], + color: [f32; 4], + }, + RoundedRect { + rect: [f32; 4], + radius: [f32; 2], + /// None fills the shape; stroke widths are positive layer units. + stroke: Option, + color: [f32; 4], + }, + Image { + image: &'a RasterImage, + rect: [f32; 4], + }, + /// A pen stroke between two points, `width` units across. + Segment { + from: [f32; 2], + to: [f32; 2], + width: f32, + /// A round pen tip caps the ends; otherwise they are square. + round: bool, + color: [f32; 4], + }, +} + +#[derive(Debug)] +pub enum RenderError { + AtlasFull, + FrameTooLarge, + UnreadableFont, + UnsupportedGlyph, + InvalidLayer, + InvalidPrimitive, + InvalidImage, + ImageBudget, + ImageTooLarge, + ImageDecode(image::ImageError), +} + +/// Cache and buffer use after the last frame, for resource probes. Texture and buffer +/// sizes are as requested, without driver overhead. +#[derive(Debug)] +pub struct Occupancy { + /// Glyph and icon atlas entries, including glyphs with no pixels. + pub glyphs: usize, + pub atlas_texels: u64, + pub images: usize, + pub image_bytes: u64, + pub vertices: usize, + pub batches: usize, + pub vertex_capacity_bytes: usize, + pub batch_capacity_bytes: usize, + pub glyph_capacity: usize, + pub image_capacity: usize, + pub atlas_bytes: u64, + pub vertex_buffer_bytes: u64, + pub within_budget: bool, +} + +pub struct Renderer { + pub device: wgpu::Device, + pub queue: wgpu::Queue, + pipeline: wgpu::RenderPipeline, + image_pipeline: wgpu::RenderPipeline, + atlas: wgpu::Texture, + bind_group: wgpu::BindGroup, + image_sampler: wgpu::Sampler, + vertex_buffer: wgpu::Buffer, + vertices: Vec, + glyphs: HashMap>, + images: HashMap, + batches: Vec, + scaler: ScaleContext, + pen: [u32; 2], + row_height: u32, +} + +impl Renderer { + pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { + let shader = device.create_shader_module(wgpu::include_wgsl!("draw.wgsl")); + let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Draw texture"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Draw"), + bind_group_layouts: &[Some(&binding_layout)], + ..Default::default() + }); + let make_pipeline = |blend| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Draw"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vertex"), + compilation_options: Default::default(), + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32], + })], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fragment"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(blend), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }) + }; + let pipeline = make_pipeline(wgpu::BlendState::ALPHA_BLENDING); + let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); + let atlas = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Glyph atlas"), + size: wgpu::Extent3d { + width: ATLAS_SIZE, + height: ATLAS_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let view = atlas.create_view(&Default::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Glyph atlas"), + layout: &pipeline.get_bind_group_layout(0), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Draw vertices"), + size: (MAX_VERTICES * size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Draw image filtering"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + queue.write_texture( + atlas.as_image_copy(), + &[255; 4], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4), + rows_per_image: Some(1), + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + Self { + device, + queue, + pipeline, + image_pipeline, + atlas, + bind_group, + image_sampler, + vertex_buffer, + vertices: Vec::new(), + glyphs: HashMap::new(), + images: HashMap::new(), + batches: Vec::new(), + scaler: ScaleContext::with_max_entries(32), + pen: [1, 0], + row_height: 1, + } + } + + pub fn clear_glyph_cache(&mut self) { + self.glyphs.clear(); + self.pen = [1, 0]; + self.row_height = 1; + } + + /// Forgets uploaded images; they upload again when next painted. + pub fn clear_images(&mut self) { + self.images.clear(); + } + + pub fn occupancy(&self) -> Occupancy { + let image_bytes = self.images.values().map(|image| image.bytes).sum(); + Occupancy { + glyphs: self.glyphs.len(), + atlas_texels: self + .glyphs + .values() + .flatten() + .map(|glyph| u64::from(glyph.width) * u64::from(glyph.height)) + .sum(), + images: self.images.len(), + image_bytes, + vertices: self.vertices.len(), + batches: self.batches.len(), + vertex_capacity_bytes: self.vertices.capacity() * size_of::(), + batch_capacity_bytes: self.batches.capacity() * size_of::(), + glyph_capacity: self.glyphs.capacity(), + image_capacity: self.images.capacity(), + atlas_bytes: u64::from(self.atlas.width()) * u64::from(self.atlas.height()) * 4, + vertex_buffer_bytes: self.vertex_buffer.size(), + within_budget: self.glyphs.len() <= MAX_GLYPHS + && self.images.len() <= MAX_IMAGES + && image_bytes <= MAX_IMAGE_BYTES + && self.vertices.len() <= MAX_VERTICES, + } + } + + /// Clears `target`, `size` device pixels, to linear `clear` and paints the layers in order. + pub fn draw( + &mut self, + target: &wgpu::TextureView, + size: [u32; 2], + clear: [f32; 4], + layers: &[Layer<'_>], + ) -> Result<(), RenderError> { + if size.contains(&0) + || clear.iter().any(|v| !v.is_finite()) + || layers.iter().any(|layer| { + !layer.scale.is_finite() + || layer.scale <= 0.0 + || layer.origin.iter().any(|v| !v.is_finite()) + || layer + .clip + .is_some_and(|clip| clip.iter().any(|v| !v.is_finite())) + }) + { + return Err(RenderError::InvalidLayer); + } + let mut active_images = HashSet::new(); + let mut image_bytes = 0; + for layer in layers { + let space = Space { + size, + scale: layer.scale, + origin: layer.origin, + }; + for primitive in layer.primitives { + if let Primitive::Image { image, rect } = primitive + && space.visible_rect(*rect)?.is_some() + && active_images.insert(image.id()) + { + image_bytes += image.pixels().len() as u64; + if image_bytes > MAX_IMAGE_BYTES || active_images.len() > MAX_IMAGES { + return Err(RenderError::ImageBudget); + } + } + } + } + for attempt in 0..2 { + self.vertices.clear(); + self.batches.clear(); + match self.prepare(size, layers, &active_images) { + Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(), + result => { + result?; + break; + } + } + } + self.queue + .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); + let mut encoder = self.device.create_command_encoder(&Default::default()); + { + let [r, g, b, a] = clear.map(f64::from); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Draw"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a }), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); + for batch in &self.batches { + let [x, y, width, height] = batch.scissor; + pass.set_scissor_rect(x, y, width, height); + pass.set_pipeline(if batch.image.is_some() { + &self.image_pipeline + } else { + &self.pipeline + }); + let binding = batch + .image + .map(|id| &self.images[&id].binding) + .unwrap_or(&self.bind_group); + pass.set_bind_group(0, binding, &[]); + pass.draw(batch.vertices.clone(), 0..1); + } + } + self.queue.submit([encoder.finish()]); + Ok(()) + } + + fn prepare( + &mut self, + size: [u32; 2], + layers: &[Layer<'_>], + active_images: &HashSet, + ) -> Result<(), RenderError> { + for layer in layers { + let space = Space { + size, + scale: layer.scale, + origin: layer.origin, + }; + let bounds = match layer.clip { + Some(clip) => space.scissor(clip), + None => [0, 0, size[0], size[1]], + }; + if bounds[2] == 0 || bounds[3] == 0 { + continue; + } + for primitive in layer.primitives { + self.primitive(space, bounds, primitive, active_images)?; + } + } + Ok(()) + } + + fn primitive( + &mut self, + space: Space, + bounds: [u32; 4], + primitive: &Primitive<'_>, + active_images: &HashSet, + ) -> Result<(), RenderError> { + let start = self.vertices.len() as u32; + let mut image_id = None; + let mut scissor = bounds; + match primitive { + Primitive::Icon { + sources, + origin, + size, + opacity, + } => { + let origin = [ + space.origin[0] + origin[0] * space.scale, + space.origin[1] + origin[1] * space.scale, + ]; + self.icon(Space { origin, ..space }, sources, *size, *opacity)?; + } + Primitive::Text { text, origin, clip } => { + let origin = [ + space.origin[0] + origin[0] * space.scale, + space.origin[1] + origin[1] * space.scale, + ]; + if origin.iter().any(|v| !v.is_finite()) { + return Err(RenderError::InvalidPrimitive); + } + if let Some(clip) = clip { + let Some(rect) = space.visible_rect(*clip)? else { + return Ok(()); + }; + scissor = intersect(bounds, space.scissor(rect)); + if scissor[2] == 0 || scissor[3] == 0 { + return Ok(()); + } + } + let space = Space { origin, ..space }; + text.runs(&mut |run| self.glyph_run(space, run))?; + } + Primitive::Rect { rect, color } => { + self.quad( + space, + [ + rect[0] * space.scale + space.origin[0], + rect[1] * space.scale + space.origin[1], + rect[2] * space.scale + space.origin[0], + rect[3] * space.scale + space.origin[1], + ], + [0.5 / ATLAS_SIZE as f32; 4], + *color, + )?; + } + Primitive::RoundedRect { + rect, + radius, + stroke, + color, + } => { + if radius + .iter() + .any(|value| !value.is_finite() || *value < 0.0) + { + return Err(RenderError::InvalidPrimitive); + } + let width = match stroke { + None => 0.0, + Some(Stroke::Solid(width) | Stroke::Dashed(width)) => { + if !width.is_finite() || *width <= 0.0 || width * space.scale == 0.0 { + return Err(RenderError::InvalidPrimitive); + } + *width + } + }; + let rect = [ + rect[0] * space.scale + space.origin[0], + rect[1] * space.scale + space.origin[1], + rect[2] * space.scale + space.origin[0], + rect[3] * space.scale + space.origin[1], + ]; + self.quad(space, rect, [0.5 / ATLAS_SIZE as f32; 4], *color)?; + let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; + if !(4.0 * (half[0] + half[1])).is_finite() { + return Err(RenderError::InvalidPrimitive); + } + let radius = if radius.contains(&0.0) { + [0.0; 2] + } else { + std::array::from_fn(|axis| (radius[axis] * space.scale).min(half[axis])) + }; + let width = (width * space.scale).min(half[0].min(half[1])); + // The stroke sign selects its dash pattern at the shader boundary. + let width = if matches!(stroke, Some(Stroke::Dashed(_))) { + -width + } else { + width + }; + for vertex in &mut self.vertices[start as usize..] { + vertex.shape = [half[0], half[1], radius[0], radius[1]]; + vertex.stroke = width; + } + } + Primitive::Segment { + from, + to, + width, + round, + color, + } => self.segment(space, *from, *to, *width, *round, *color)?, + Primitive::Image { image, rect } => { + if let Some(rect) = space.visible_rect(*rect)? { + self.image(image, active_images)?; + image_id = Some(image.id()); + self.quad(space, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?; + } + } + } + let end = self.vertices.len() as u32; + if start != end { + if let Some(last) = self.batches.last_mut() + && last.image == image_id + && last.scissor == scissor + { + last.vertices.end = end; + } else { + self.batches.push(Batch { + vertices: start..end, + image: image_id, + scissor, + }); + } + } + Ok(()) + } + + fn image(&mut self, image: &RasterImage, active: &HashSet) -> Result<(), RenderError> { + if self.images.contains_key(&image.id()) { + return Ok(()); + } + if image + .size + .iter() + .any(|v| *v > self.device.limits().max_texture_dimension_2d) + { + return Err(RenderError::ImageTooLarge); + } + let bytes = image.pixels().len() as u64; + while self.images.values().map(|i| i.bytes).sum::() + bytes > MAX_IMAGE_BYTES + || self.images.len() >= MAX_IMAGES + { + let id = self + .images + .keys() + .find(|id| !active.contains(id)) + .copied() + .ok_or(RenderError::ImageBudget)?; + self.images.remove(&id); + } + let size = wgpu::Extent3d { + width: image.size[0], + height: image.size[1], + depth_or_array_layers: 1, + }; + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("Draw image"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.queue.write_texture( + texture.as_image_copy(), + image.pixels(), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(image.size[0] * 4), + rows_per_image: Some(image.size[1]), + }, + size, + ); + let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Draw image"), + layout: &self.pipeline.get_bind_group_layout(0), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView( + &texture.create_view(&Default::default()), + ), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.image_sampler), + }, + ], + }); + self.images + .insert(image.id(), CachedImage { binding, bytes }); + Ok(()) + } + + fn glyph_run(&mut self, space: Space, run: GlyphRun<'_>) -> Result<(), RenderError> { + let scale = space.scale; + let origin = space.origin; + let line_top = run.line[0] * scale + origin[1]; + let line_height = run.line[1] * scale; + if line_top + line_height < 0.0 || line_top > space.size[1] as f32 { + return Ok(()); + } + let size = run.size * scale; + if !size.is_finite() || size > ATLAS_SIZE as f32 { + return Err(RenderError::AtlasFull); + } + for glyph in run.glyphs { + let x = glyph.x * scale + origin[0]; + let y = glyph.y * scale + origin[1]; + // Quantization affects raster coverage only, never advances or line breaks. + let x = (x * 4.0).round() * 0.25; + let y = (y * 4.0).round() * 0.25; + let glyph_id = glyph + .id + .try_into() + .map_err(|_| RenderError::UnsupportedGlyph)?; + let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; + let key = AtlasKey::Text { + font: run.font.id(), + index: run.index, + glyph: glyph_id, + size: size.to_bits(), + coords: run.coords.to_vec(), + phase, + embolden: run.embolden, + skew: run.skew.unwrap_or(0.0).to_bits(), + }; + let cached = if let Some(cached) = self.glyphs.get(&key) { + *cached + } else { + if self.glyphs.len() >= MAX_GLYPHS { + return Err(RenderError::AtlasFull); + } + let font = FontRef::from_index(run.font.as_ref(), run.index as usize) + .ok_or(RenderError::UnreadableFont)?; + let mut scaler = self + .scaler + .builder_with_id(font, [run.font.id(), u64::from(run.index)]) + .size(size) + .hint(false) + .normalized_coords(run.coords.iter().copied()) + .build(); + let mut render = Render::new(&[ + Source::ColorOutline(0), + Source::ColorBitmap(StrikeWith::BestFit), + Source::Outline, + ]); + // Swash rasterizes outlines in Y-up coordinates. + render.format(Format::Alpha).offset(Vector::new( + f32::from(phase[0]) * 0.25, + -f32::from(phase[1]) * 0.25, + )); + if run.embolden { + render.embolden(size / 24.0); + } + if let Some(skew) = run.skew { + render.transform(Some(Transform::skew( + Angle::from_degrees(skew), + Angle::from_degrees(0.0), + ))); + } + let image = render.render(&mut scaler, glyph_id); + let cached = if let Some(image) = + image.filter(|i| i.placement.width > 0 && i.placement.height > 0) + { + Some(self.upload(image)?) + } else { + None + }; + self.glyphs.insert(key, cached); + cached + }; + if let Some(glyph) = cached { + let mut left = x.floor() + glyph.left as f32; + let mut top = y.floor() - glyph.top as f32; + let mut width = glyph.width as f32; + let mut height = glyph.height as f32; + if glyph.color { + let ratio = (line_height / height).min(1.0); + left += width * (1.0 - ratio) * 0.5; + width *= ratio; + height *= ratio; + top = top.clamp(line_top, (line_top + line_height - height).max(line_top)); + } + let atlas_size = ATLAS_SIZE as f32; + self.quad( + space, + [left, top, left + width, top + height], + [ + glyph.x as f32 / atlas_size, + glyph.y as f32 / atlas_size, + (glyph.x + glyph.width) as f32 / atlas_size, + (glyph.y + glyph.height) as f32 / atlas_size, + ], + if glyph.color { [1.0; 4] } else { run.color }, + )?; + } + } + for decoration in run.decorations { + let x = decoration.x * scale + origin[0]; + let y = decoration.y * scale + origin[1]; + self.quad( + space, + [ + x, + y, + x + decoration.width * scale, + y + (decoration.thickness * scale).max(1.0), + ], + [0.5 / ATLAS_SIZE as f32; 4], + decoration.color, + )?; + } + Ok(()) + } + + fn upload(&mut self, image: swash::scale::image::Image) -> Result { + let p = image.placement; + if p.width + 2 > ATLAS_SIZE || p.height + 2 > ATLAS_SIZE { + return Err(RenderError::AtlasFull); + } + if self.pen[0] + p.width + 1 > ATLAS_SIZE { + self.pen[0] = 0; + self.pen[1] += self.row_height + 1; + self.row_height = 0; + } + if self.pen[1] + p.height + 1 > ATLAS_SIZE { + return Err(RenderError::AtlasFull); + } + let cached = AtlasGlyph { + x: self.pen[0], + y: self.pen[1], + width: p.width, + height: p.height, + left: p.left, + top: p.top, + color: image.content == Content::Color, + }; + let rgba = match image.content { + Content::Mask => image + .data + .iter() + .flat_map(|a| [255, 255, 255, *a]) + .collect(), + _ => image.data, + }; + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + origin: wgpu::Origin3d { + x: cached.x, + y: cached.y, + z: 0, + }, + ..self.atlas.as_image_copy() + }, + &rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(p.width * 4), + rows_per_image: Some(p.height), + }, + wgpu::Extent3d { + width: p.width, + height: p.height, + depth_or_array_layers: 1, + }, + ); + self.pen[0] += p.width + 1; + self.row_height = self.row_height.max(p.height); + Ok(cached) + } + + fn icon( + &mut self, + space: Space, + sources: &'static [&'static str], + size: f32, + opacity: f32, + ) -> Result<(), RenderError> { + let size = size * space.scale; + let [x, y] = space.origin.map(|v| (v * 4.0).round() * 0.25); + if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) { + return Err(RenderError::InvalidPrimitive); + } + if x + size + 1.0 < 0.0 + || y + size + 1.0 < 0.0 + || x - 1.0 > space.size[0] as f32 + || y - 1.0 > space.size[1] as f32 + { + return Ok(()); + } + if size + 3.0 > ATLAS_SIZE as f32 { + return Err(RenderError::AtlasFull); + } + let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; + let key = AtlasKey::Icon { + sources, + size: size.to_bits(), + phase, + }; + let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) { + *glyph + } else { + if self.glyphs.len() >= MAX_GLYPHS { + return Err(RenderError::AtlasFull); + } + let glyph = self.upload(icon::rasterize(sources, size, phase))?; + self.glyphs.insert(key, Some(glyph)); + glyph + }; + let left = x.floor(); + let top = y.floor(); + let atlas = ATLAS_SIZE as f32; + self.quad( + space, + [ + left, + top, + left + glyph.width as f32, + top + glyph.height as f32, + ], + [ + glyph.x as f32 / atlas, + glyph.y as f32 / atlas, + (glyph.x + glyph.width) as f32 / atlas, + (glyph.y + glyph.height) as f32 / atlas, + ], + [1.0, 1.0, 1.0, opacity], + ) + } + + /// Draws the segment as a capsule in its own frame, reusing the rounded-rectangle distance. + fn segment( + &mut self, + space: Space, + from: [f32; 2], + to: [f32; 2], + width: f32, + round: bool, + color: [f32; 4], + ) -> Result<(), RenderError> { + let pixel = |p: [f32; 2]| { + [ + p[0] * space.scale + space.origin[0], + p[1] * space.scale + space.origin[1], + ] + }; + let [from, to] = [pixel(from), pixel(to)]; + // Hairlines stay one device pixel wide. + let radius = (width * space.scale).max(1.0) * 0.5; + if from.iter().chain(&to).chain(&color).any(|v| !v.is_finite()) || !radius.is_finite() { + return Err(RenderError::InvalidPrimitive); + } + let pad = radius + 1.0; + if from[0].max(to[0]) + pad < 0.0 + || from[1].max(to[1]) + pad < 0.0 + || from[0].min(to[0]) - pad > space.size[0] as f32 + || from[1].min(to[1]) - pad > space.size[1] as f32 + { + return Ok(()); + } + if self.vertices.len() + 6 > MAX_VERTICES { + return Err(RenderError::FrameTooLarge); + } + let delta = [to[0] - from[0], to[1] - from[1]]; + let length = delta[0].hypot(delta[1]); + let along = if length > 0.0 { + [delta[0] / length, delta[1] / length] + } else { + [1.0, 0.0] + }; + let across = [-along[1], along[0]]; + let center = [(from[0] + to[0]) * 0.5, (from[1] + to[1]) * 0.5]; + let half = [length * 0.5 + radius, radius]; + let corner = if round { radius } else { 0.0 }; + let [hx, hy] = [half[0] + 1.0, half[1] + 1.0]; + for local in [ + [-hx, -hy], + [-hx, hy], + [hx, hy], + [-hx, -hy], + [hx, hy], + [hx, -hy], + ] { + let x = center[0] + along[0] * local[0] + across[0] * local[1]; + let y = center[1] + along[1] * local[0] + across[1] * local[1]; + self.vertices.push(Vertex { + position: [ + x * 2.0 / space.size[0] as f32 - 1.0, + 1.0 - y * 2.0 / space.size[1] as f32, + ], + uv: [0.5 / ATLAS_SIZE as f32; 2], + color, + local, + shape: [half[0], half[1], corner, corner], + stroke: 0.0, + }); + } + Ok(()) + } + + fn quad( + &mut self, + space: Space, + rect: [f32; 4], + uv: [f32; 4], + color: [f32; 4], + ) -> Result<(), RenderError> { + if rect.iter().chain(&color).any(|v| !v.is_finite()) + || rect[2] < rect[0] + || rect[3] < rect[1] + { + return Err(RenderError::InvalidPrimitive); + } + if rect[2] < 0.0 + || rect[3] < 0.0 + || rect[0] > space.size[0] as f32 + || rect[1] > space.size[1] as f32 + { + return Ok(()); + } + if self.vertices.len() + 6 > MAX_VERTICES { + return Err(RenderError::FrameTooLarge); + } + let x0 = rect[0] * 2.0 / space.size[0] as f32 - 1.0; + let x1 = rect[2] * 2.0 / space.size[0] as f32 - 1.0; + let y0 = 1.0 - rect[1] * 2.0 / space.size[1] as f32; + let y1 = 1.0 - rect[3] * 2.0 / space.size[1] as f32; + if [x0, x1, y0, y1].iter().any(|v| !v.is_finite()) { + return Err(RenderError::InvalidPrimitive); + } + let [hx, hy] = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; + for (position, uv, local) in [ + ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), + ([x0, y1], [uv[0], uv[3]], [-hx, hy]), + ([x1, y1], [uv[2], uv[3]], [hx, hy]), + ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), + ([x1, y1], [uv[2], uv[3]], [hx, hy]), + ([x1, y0], [uv[2], uv[1]], [hx, -hy]), + ] { + self.vertices.push(Vertex { + position, + uv, + color, + local, + shape: [0.0; 4], + stroke: 0.0, + }); + } + Ok(()) + } +} + +fn srgb_byte(value: f32) -> u8 { + let srgb = if value <= 0.0031308 { + value * 12.92 + } else { + 1.055 * value.powf(1.0 / 2.4) - 0.055 + }; + (srgb * 255.0).round() as u8 +} + +/// Linear RGBA of an opaque sRGB colour. +pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] { + let linear = |byte: u8| { + let value = f32::from(byte) / 255.0; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + [linear(red), linear(green), linear(blue), 1.0] +} + +#[cfg(test)] +mod tests { + use super::*; + use parley::{ + FontContext, FontFamily, FontFamilyName, GenericFamily, Layout, LayoutContext, + PositionedLayoutItem, StyleProperty, + }; + use std::time::Duration; + + const CHECKBOX: &str = r##""##; + const MARK: &str = r##""##; + const DOT: &str = r##""##; + + struct Text(Layout<[u8; 4]>); + + impl Glyphs for Text { + fn runs( + &self, + paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, + ) -> Result<(), RenderError> { + for line in self.0.lines() { + let metrics = line.metrics(); + for item in line.items() { + if let PositionedLayoutItem::GlyphRun(run) = item { + paint_parley_run( + &run, + metrics.baseline, + 0.0, + [metrics.block_min_coord, metrics.line_height], + |_| [0.0, 0.0, 0.0, 1.0], + paint, + )?; + } + } + } + Ok(()) + } + } + + fn shape(text: &str, family: &str, size: f32, underline: bool, width: f32) -> Text { + let mut fonts = FontContext::default(); + let mut context = LayoutContext::default(); + let mut builder = context.ranged_builder(&mut fonts, text, 1.0, false); + builder.push_default(StyleProperty::FontFamily(FontFamily::List( + vec![ + FontFamilyName::named(family), + GenericFamily::SansSerif.into(), + ] + .into(), + ))); + builder.push_default(StyleProperty::FontSize(size)); + builder.push_default(StyleProperty::Underline(underline)); + let mut layout = builder.build(text); + layout.break_all_lines(Some(width)); + Text(layout) + } + + #[test] + fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() { + let mut encoded = Vec::new(); + let mut encoder = png::Encoder::new(&mut encoded, 2, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let rgba = [255, 64, 0, 255, 0, 128, 255, 128]; + encoder + .write_header() + .unwrap() + .write_image_data(&rgba) + .unwrap(); + let decoded = RasterImage::decode(&encoded).unwrap(); + assert_eq!(decoded.size, [2, 1]); + assert_eq!(decoded.pixels(), [255, 64, 0, 255, 0, 93, 188, 128]); + for end in 0..encoded.len() - 12 { + assert!(RasterImage::decode(&encoded[..end]).is_err(), "{end}"); + } + + let mut jpeg = Vec::new(); + image::codecs::jpeg::JpegEncoder::new(&mut jpeg) + .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8) + .unwrap(); + let decoded = RasterImage::decode(&jpeg).unwrap(); + assert_eq!(decoded.size, [4, 4]); + assert_eq!(decoded.pixels(), [255; 4 * 4 * 4]); + + let mut gif = Vec::new(); + image::codecs::gif::GifEncoder::new(&mut gif) + .encode(&[255; 2 * 2 * 4], 2, 2, image::ExtendedColorType::Rgba8) + .unwrap(); + let decoded = RasterImage::decode(&gif).unwrap(); + assert_eq!(decoded.size, [2, 2]); + assert_eq!(decoded.pixels(), [255; 2 * 2 * 4]); + + for size in [[8192, 8192], [16_385, 1]] { + let mut header = Vec::new(); + let mut encoder = png::Encoder::new(&mut header, size[0], size[1]); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer.write_chunk(png::chunk::IDAT, &[]).unwrap(); + drop(writer); + let error = RasterImage::decode(&header).err().unwrap(); + if size[0] == 8192 { + assert!(matches!(error, RenderError::ImageBudget), "{error:?}"); + } else { + assert!( + matches!( + error, + RenderError::ImageDecode(image::ImageError::Limits(_)) + ), + "{error:?}" + ); + } + } + } + + #[test] + fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() { + assert!(matches!( + RasterImage::new([0, 1], Vec::new()), + Err(RenderError::InvalidImage) + )); + assert!(matches!( + RasterImage::new([2, 2], vec![255; 15]), + Err(RenderError::InvalidImage) + )); + assert!(matches!( + RasterImage::new([u32::MAX; 2], Vec::new()), + Err(RenderError::InvalidImage) + )); + let image = RasterImage::new([1, 1], vec![1, 2, 3, 255]).unwrap(); + assert_eq!(image.id(), image.clone().id()); + } + + struct Target { + texture: wgpu::Texture, + readback: wgpu::Buffer, + } + + impl Target { + fn new(device: &wgpu::Device) -> Self { + Self { + texture: device.create_texture(&wgpu::TextureDescriptor { + label: Some("Draw readback test"), + size: wgpu::Extent3d { + width: 512, + height: 256, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }), + readback: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Draw readback"), + size: 512 * 256 * 4, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + } + } + + fn draw(&self, renderer: &mut Renderer, layers: &[Layer<'_>]) -> Result<(), RenderError> { + renderer.draw( + &self.texture.create_view(&Default::default()), + [512, 256], + [1.0; 4], + layers, + ) + } + + fn capture(&self, renderer: &Renderer) -> Vec { + let mut encoder = renderer.device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + self.texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &self.readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(512 * 4), + rows_per_image: Some(256), + }, + }, + wgpu::Extent3d { + width: 512, + height: 256, + depth_or_array_layers: 1, + }, + ); + renderer.queue.submit([encoder.finish()]); + let (sender, receiver) = std::sync::mpsc::channel(); + self.readback + .map_async(wgpu::MapMode::Read, .., move |result| { + sender.send(result).unwrap(); + }); + renderer + .device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(Duration::from_secs(5)), + }) + .unwrap(); + receiver + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + let capture = self.readback.get_mapped_range(..).unwrap().to_vec(); + self.readback.unmap(); + capture + } + } + + fn page<'a>(primitives: &'a [Primitive<'a>]) -> [Layer<'a>; 1] { + [Layer { + scale: 2.0, + origin: [24.0; 2], + clip: None, + primitives, + }] + } + + #[test] + #[ignore = "requires a native GPU adapter"] + fn eviction_and_repaint_reproduce_pixels() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + let (device, queue) = + pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let format = wgpu::TextureFormat::Rgba8UnormSrgb; + let target = Target::new(&device); + let mut renderer = Renderer::new(device, queue, format); + let layout = shape("Hello 🌳\nCafé e\u{301} שלום", "Arial", 11.0, true, 220.0); + let mut captures = Vec::new(); + let image = RasterImage::new( + [2, 2], + vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 0], + ) + .unwrap(); + let transparent_edge = + RasterImage::new([2, 1], vec![255, 0, 0, 255, 0, 0, 255, 0]).unwrap(); + let icons: [&'static [&'static str]; 4] = [&[CHECKBOX], &[CHECKBOX, MARK], &[DOT], &[MARK]]; + let phase_layout = shape("H", "Arial", 11.0, false, 20.0); + let mut primitives = vec![ + Primitive::Rect { + rect: [0.0, 0.0, 70.0, 14.0], + color: [0.55, 0.73, 1.0, 0.5], + }, + Primitive::Text { + clip: None, + text: &layout, + origin: [0.0; 2], + }, + Primitive::Text { + clip: None, + text: &layout, + origin: [8.0, 64.0], + }, + Primitive::Image { + image: &image, + rect: [128.0, 8.0, 160.0, 40.0], + }, + Primitive::Rect { + rect: [128.0, 8.0, 132.0, 12.0], + color: [0.0, 0.0, 1.0, 1.0], + }, + Primitive::Image { + image: &transparent_edge, + rect: [176.0, 8.0, 208.0, 40.0], + }, + Primitive::RoundedRect { + rect: [128.0, 48.0, 148.0, 68.0], + radius: [5.0, 10.0], + stroke: None, + color: [1.0, 0.0, 0.0, 1.0], + }, + Primitive::RoundedRect { + rect: [164.0, 48.0, 184.0, 68.0], + radius: [5.0; 2], + stroke: Some(Stroke::Solid(1.0)), + color: [0.0, 0.0, 1.0, 1.0], + }, + Primitive::RoundedRect { + rect: [200.0, 48.0, 240.0, 68.0], + radius: [3.0, 10.0], + stroke: Some(Stroke::Dashed(1.0)), + color: [0.0, 0.0, 1.0, 1.0], + }, + Primitive::Segment { + from: [226.0, 104.0], + to: [240.0, 104.0], + width: 4.0, + round: true, + color: [1.0, 0.0, 0.0, 1.0], + }, + ]; + for (index, sources) in icons.iter().enumerate() { + primitives.push(Primitive::Icon { + sources, + origin: [128.125 + 20.0 * index as f32, 80.25], + size: 12.0, + opacity: 1.0, + }); + } + primitives.extend((0..9).map(|step| Primitive::Text { + clip: None, + text: &phase_layout, + origin: [8.0 + 24.0 * step as f32, 96.0 + 0.125 * step as f32], + })); + for pass in 0..6 { + if pass == 2 { + renderer.clear_glyph_cache(); + renderer.images.clear(); + } else if pass == 3 { + renderer = Renderer::new(renderer.device.clone(), renderer.queue.clone(), format); + } + let clipping = [ + Primitive::Text { + text: &layout, + origin: [0.0; 2], + clip: (pass == 5).then_some([2.5, 1.25, 30.75, 20.5]), + }, + Primitive::Rect { + rect: [40.0, 40.0, 70.0, 50.0], + color: [0.0, 0.0, 1.0, 1.0], + }, + ]; + target + .draw( + &mut renderer, + &page(if pass < 4 { &primitives } else { &clipping }), + ) + .unwrap(); + captures.push(target.capture(&renderer)); + } + let centroids: Vec<_> = (0..9) + .map(|step| { + let mut weight = 0.0_f64; + let mut moment = 0.0_f64; + for y in 212..252 { + for x in (40 + 48 * step)..(72 + 48 * step) { + let value = captures[0][(y * 512 + x) * 4]; + let coverage = 1.0 - f64::from(srgb(value, 0, 0)[0]); + weight += coverage; + moment += coverage * y as f64; + } + } + assert!(weight > 10.0, "phase sample {step} was not painted"); + moment / weight + }) + .collect(); + let pixel = |x: usize, y: usize| &captures[0][(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; + assert_eq!(pixel(490, 232), [255, 0, 0, 255]); + assert_eq!(pixel(473, 232), [255, 0, 0, 255]); + assert_eq!(pixel(490, 238), [255; 4]); + assert_eq!(pixel(472, 228), [255; 4]); + assert_eq!(pixel(300, 140), [255, 0, 0, 255]); + assert_eq!(pixel(280, 120), [255; 4]); + assert_eq!(pixel(282, 124), [255; 4]); + assert_eq!(pixel(281, 140), [255, 0, 0, 255]); + assert_eq!(pixel(372, 140), [255; 4]); + assert_eq!(pixel(372, 120), [0, 0, 255, 255]); + assert_eq!(pixel(352, 120), [255; 4]); + assert_eq!(pixel(424, 120), [255; 4]); + assert_eq!(pixel(464, 140), [255; 4]); + assert!((440..480).any(|x| pixel(x, 120)[0] < 40)); + assert!((440..480).any(|x| pixel(x, 120)[0] > 250)); + for x in 440..472 { + assert!(pixel(x, 120)[0].abs_diff(pixel(x + 8, 120)[0]) <= 1); + } + for (step, centroid) in centroids.iter().enumerate() { + let movement = centroid - centroids[0]; + assert!( + (movement - step as f64 * 0.25).abs() < 0.06, + "quarter-pixel step {step} moved the glyph by {movement} px" + ); + } + assert!(!renderer.glyphs.is_empty()); + assert!(renderer.glyphs.len() <= MAX_GLYPHS); + assert_eq!( + renderer + .glyphs + .keys() + .filter(|key| matches!(key, AtlasKey::Icon { .. })) + .count(), + 4 + ); + for x in [280, 320, 360, 400] { + let colored = (184..210) + .flat_map(|y| (x..x + 26).map(move |x| (y * 512 + x) * 4)) + .filter(|offset| captures[0][*offset..*offset + 3].iter().any(|v| *v < 180)) + .count(); + assert!(colored > 10, "icon at {x} was not painted"); + } + let count = renderer.glyphs.len(); + target + .draw( + &mut renderer, + &page(&[Primitive::Icon { + sources: icons[0], + origin: [10000.0; 2], + size: 12.0, + opacity: 1.0, + }]), + ) + .unwrap(); + assert_eq!(renderer.glyphs.len(), count); + assert!( + captures[0] + .chunks_exact(4) + .filter(|pixel| pixel[0] < 200) + .count() + > 100 + ); + assert_eq!(captures[0], captures[1]); + assert_eq!(captures[0], captures[2]); + assert_eq!(captures[0], captures[3]); + assert_ne!(captures[4], captures[5]); + for y in 0..256 { + for x in 0..512 { + let offset = (y * 512 + x) * 4; + let expected = if ((29..86).contains(&x) && (26..65).contains(&y)) + || ((104..164).contains(&x) && (104..124).contains(&y)) + { + &captures[4][offset..offset + 4] + } else { + &[255; 4] + }; + assert_eq!( + &captures[5][offset..offset + 4], + expected, + "clipping at {x},{y}" + ); + } + } + for (x, y, color) in [ + (288, 48, [255, 0, 0, 255]), + (336, 96, [255; 4]), + (284, 44, [0, 0, 255, 255]), + ] { + let offset = (y * 512 + x) * 4; + assert_eq!(captures[0][offset..offset + 4], color); + } + let edge = &captures[0][(48 * 512 + 408) * 4..(48 * 512 + 408) * 4 + 4]; + assert_eq!(edge[0], 255, "{edge:?}"); + assert_eq!(edge[1], edge[2], "{edge:?}"); + assert!((180..=200).contains(&edge[1]), "{edge:?}"); + assert_eq!(renderer.images.len(), 2); + for primitive in [ + Primitive::RoundedRect { + rect: [-1e38, 0.0, 1e38, 1e38], + radius: [3.0; 2], + stroke: Some(Stroke::Dashed(1.0)), + color: [1.0; 4], + }, + Primitive::RoundedRect { + rect: [0.0, 0.0, 10.0, 10.0], + radius: [f32::NAN, 2.0], + stroke: None, + color: [1.0; 4], + }, + Primitive::RoundedRect { + rect: [0.0, 0.0, 10.0, 10.0], + radius: [2.0; 2], + stroke: Some(Stroke::Solid(0.0)), + color: [1.0; 4], + }, + Primitive::RoundedRect { + rect: [0.0, 0.0, 10.0, 10.0], + radius: [2.0; 2], + stroke: Some(Stroke::Dashed(f32::NAN)), + color: [1.0; 4], + }, + Primitive::Rect { + rect: [0.0, 0.0, f32::NAN, 1.0], + color: [1.0; 4], + }, + Primitive::Rect { + rect: [2.0, 0.0, 1.0, 1.0], + color: [1.0; 4], + }, + Primitive::Text { + clip: None, + text: &layout, + origin: [f32::MAX; 2], + }, + Primitive::Text { + clip: Some([0.0, 0.0, f32::NAN, 1.0]), + text: &layout, + origin: [0.0; 2], + }, + Primitive::Text { + clip: Some([2.0, 0.0, 1.0, 1.0]), + text: &layout, + origin: [0.0; 2], + }, + ] { + assert!(matches!( + target.draw(&mut renderer, &page(&[primitive])), + Err(RenderError::InvalidPrimitive) + )); + } + let too_many: Vec<_> = (0..=MAX_IMAGES) + .map(|_| RasterImage::new([1, 1], vec![255; 4]).unwrap()) + .collect(); + let primitives: Vec<_> = too_many + .iter() + .map(|image| Primitive::Image { + image, + rect: [0.0, 0.0, 1.0, 1.0], + }) + .collect(); + assert!(matches!( + target.draw(&mut renderer, &page(&primitives)), + Err(RenderError::ImageBudget) + )); + let primitives: Vec<_> = too_many + .iter() + .map(|image| Primitive::Image { + image, + rect: [-20.0, -20.0, -19.0, -19.0], + }) + .collect(); + target.draw(&mut renderer, &page(&primitives)).unwrap(); + assert_eq!(renderer.images.len(), 2); + let mut seen = HashSet::from([image.id(), transparent_edge.id()]); + let mut previous = None; + for _ in 0..5 { + let large = RasterImage::new([2048; 2], vec![255; 2048 * 2048 * 4]).unwrap(); + seen.insert(large.id()); + let mut primitives = vec![Primitive::Image { + image: &large, + rect: [0.0, 0.0, 16.0, 16.0], + }]; + if let Some(image) = &previous { + primitives.push(Primitive::Image { + image, + rect: [16.0, 0.0, 32.0, 16.0], + }); + } + target.draw(&mut renderer, &page(&primitives)).unwrap(); + assert!(renderer.images.contains_key(&large.id())); + if let Some(image) = &previous { + assert!(renderer.images.contains_key(&image.id())); + } + assert!(renderer.occupancy().within_budget); + previous = Some(large); + } + assert!(renderer.images.len() <= MAX_IMAGES); + assert!(seen.iter().any(|id| !renderer.images.contains_key(id))); + renderer.images.clear(); + target + .draw( + &mut renderer, + &page(&[Primitive::Image { + image: &image, + rect: [128.0, 8.0, 160.0, 40.0], + }]), + ) + .unwrap(); + assert_eq!(renderer.images.len(), 1); + assert!(renderer.images.contains_key(&image.id())); + } + + #[test] + #[ignore = "requires a native GPU adapter"] + fn layers_transform_and_clip_independently() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + let (device, queue) = + pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let target = Target::new(&device); + let mut renderer = Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); + let fill = [Primitive::Rect { + rect: [0.0, 0.0, 300.0, 100.0], + color: [1.0, 0.0, 0.0, 1.0], + }]; + let chrome = [Primitive::Rect { + rect: [0.0, 0.0, 10.0, 10.0], + color: [0.0, 0.0, 1.0, 1.0], + }]; + target + .draw( + &mut renderer, + &[ + Layer { + scale: 1.0, + origin: [0.0; 2], + clip: Some([100.0, 50.0, 200.5, 80.0]), + primitives: &fill, + }, + Layer { + scale: 2.0, + origin: [300.0, 10.0], + clip: None, + primitives: &chrome, + }, + ], + ) + .unwrap(); + let capture = target.capture(&renderer); + let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; + assert_eq!(pixel(10, 10), [255; 4], "clipped away"); + assert_eq!(pixel(100, 50), [255, 0, 0, 255]); + assert_eq!( + pixel(200, 79), + [255, 0, 0, 255], + "partial pixels round outward" + ); + assert_eq!(pixel(99, 60), [255; 4]); + assert_eq!(pixel(201, 60), [255; 4]); + assert_eq!(pixel(150, 80), [255; 4]); + assert_eq!(pixel(319, 29), [0, 0, 255, 255]); + assert_eq!(pixel(320, 30), [255; 4]); + assert!(matches!( + target.draw( + &mut renderer, + &[Layer { + scale: 0.0, + origin: [0.0; 2], + clip: None, + primitives: &fill, + }] + ), + Err(RenderError::InvalidLayer) + )); + } +} diff --git a/crates/draw/src/text.rs b/crates/draw/src/text.rs new file mode 100644 index 0000000000000000000000000000000000000000..c17d43d67bf0ac01c9e88c38495c895c9633ad18 --- /dev/null +++ b/crates/draw/src/text.rs @@ -0,0 +1,113 @@ +use crate::RenderError; +use parley::fontique::Blob; + +/// Text the renderer can paint, as glyph runs in layer units. Owners recover their +/// concrete text from a primitive through `Any`. +pub trait Glyphs: std::any::Any { + fn runs( + &self, + paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, + ) -> Result<(), RenderError>; +} + +/// Glyphs sharing one font, size and colour. +pub struct GlyphRun<'a> { + pub font: &'a Blob, + /// The face within a font collection. + pub index: u32, + pub size: f32, + /// Variation coordinates in F2Dot14 bits. + pub coords: &'a [i16], + pub embolden: bool, + /// Synthesized oblique angle in degrees. + pub skew: Option, + pub color: [f32; 4], + /// Top and height of the run's line: lines outside the target are skipped, and + /// colour glyphs shrink to fit. + pub line: [f32; 2], + pub glyphs: &'a mut dyn Iterator, + /// Underline and strikethrough, painted after the glyphs. + pub decorations: &'a [Decoration], +} + +/// A glyph's baseline position. +#[derive(Clone, Copy)] +pub struct Glyph { + pub id: u32, + pub x: f32, + pub y: f32, +} + +/// A decoration line whose top edge is `y`; it stays at least one device pixel thick. +#[derive(Clone, Copy)] +pub struct Decoration { + pub x: f32, + pub y: f32, + pub width: f32, + pub thickness: f32, + pub color: [f32; 4], +} + +/// Paints one parley glyph run with its line's baseline moved to `baseline` and its +/// glyphs raised by `rise`. +pub fn paint_parley_run( + run: &parley::GlyphRun<'_, B>, + baseline: f32, + rise: f32, + line: [f32; 2], + color: impl Fn(&B) -> [f32; 4], + paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, +) -> Result<(), RenderError> { + let shaped = run.run(); + let font = &shaped.font().font; + let coords: Vec = shaped + .normalized_coords() + .iter() + .map(|c| c.to_bits()) + .collect(); + let synthesis = shaped.synthesis(); + let style = run.style(); + let metrics = shaped.font_metrics(); + let decorations: Vec<_> = [ + ( + &style.underline, + metrics.underline_offset, + metrics.underline_size, + ), + ( + &style.strikethrough, + metrics.strikethrough_offset, + metrics.strikethrough_size, + ), + ] + .into_iter() + .filter_map(|(decoration, offset, thickness)| { + let decoration = decoration.as_ref()?; + Some(Decoration { + x: run.offset(), + y: baseline - rise - decoration.offset.unwrap_or(offset), + width: run.advance(), + thickness: decoration.size.unwrap_or(thickness), + color: color(&decoration.brush), + }) + }) + .collect(); + let line_baseline = run.baseline(); + let mut glyphs = run.positioned_glyphs().map(|glyph| Glyph { + id: glyph.id, + x: glyph.x, + y: glyph.y + baseline - line_baseline - rise, + }); + paint(GlyphRun { + font: &font.data, + index: font.index, + size: shaped.font_size(), + coords: &coords, + embolden: synthesis.embolden(), + skew: synthesis.skew(), + color: color(&style.brush), + line, + glyphs: &mut glyphs, + decorations: &decorations, + }) +} diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index b30bef5cd01d12ae45347b46707d96ce3ed4ecd6..0286e58a4ca6e875265eb89e0b3eb2af8161a4a3 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -6,6 +6,7 @@ publish = false [dependencies] canvas = { path = "../canvas", features = ["interaction"] } +draw = { path = "../draw" } onestore = { path = "../onestore" } notebook = { path = "../notebook" } pollster = "0.4" diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 88f51531175861558ba6979f72b0672b9139d04f..35f617cfb4287891e853b09a3ea360a79f3c43a7 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1,6 +1,6 @@ mod macos; -use canvas::gpu::{Renderer, page::PageScene}; +use canvas::gpu::page::PageScene; use canvas::interaction::{ Cursor, Key as PageKey, Modifiers, NamedKey as PageNamedKey, PageView, Request, Response, TextColors, accessibility, @@ -11,6 +11,7 @@ use canvas::{ editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline}, layout::TextEngine, }; +use draw::Renderer; use onestore::document::Format; use onestore::page::Page; use onestore::page::text::Paragraph; @@ -604,8 +605,9 @@ impl State { self.renderer .draw( &frame.texture.create_view(&Default::default()), - viewport, - &primitives, + viewport.size, + [1.0; 4], + &[viewport.layer(&primitives)], ) .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; self.window.pre_present_notify(); diff --git a/tools/canvas/README.md b/tools/canvas/README.md index 1942640d97e8a3c03b777e2241b0e45471386070..e305857fcd524565de2109988d9cb4750b340a02 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -10,6 +10,7 @@ The host opens nonempty UTF-8 text as paragraphs in one temporary text outline. python3 tools/canvas/build_macos.py "target/Snowbound.app/Contents/MacOS/Snowbound" cargo test -p canvas --features interaction +cargo test -p draw -- --ignored cargo test -p canvas --features gpu gpu:: -- --ignored ``` @@ -30,7 +31,9 @@ onestore::Format ↓ canvas text edits · inverse history · composition · paragraph layout · structural edits ↓ -canvas::gpu Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches +canvas::gpu page scene · text runs, tag icons, pictures and ink as draw primitives + ↓ +draw Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches · layers ↓ canvas::interaction hit layers · drags · placement grid · picture handles · chrome · key routing · scrolling and zoom · caret blink · AccessKit tree @@ -40,9 +43,9 @@ snowbound Winit window and events · AppKit pickers and quit guard · clipboar The renderer allocates a 2048² RGBA glyph atlas and a 3.5 MiB vertex buffer, limits combined text-glyph and tag-icon entries to 8192, and rebuilds a frame after atlas eviction. Font scaling uses 32 cache entries. These limits bound those caches; they are not a process-memory or worst-case rasterization budget. Drawing and hit-testing share Windows-metric line boxes. First-baseline fidelity and substitute-font metric selection remain experimental; the `windows_*` probe values expose that policy independently from Parley's original metrics. -Drawing accepts a flat sequence of positioned text layouts, tag icons, rectangles and immutable raster images. Consecutive primitives with the same texture share a batch without changing paint order. The host emits selection, composition and caret rectangles through that same interface; repainting multiple placements reuses the retained layout and glyph cache. +The `draw` crate owns the renderer so the page and application chrome share one pass, atlas and image cache. It paints layers, each a transform and device clip over a flat sequence of glyph runs, SVG icons, rectangles, pen strokes and immutable raster images. Text reaches it through the `Glyphs` trait, which visits positioned glyph runs, so the page's Windows-metric line boxes stay in `canvas` and the renderer never measures text. Consecutive primitives with the same texture and clip share a batch without changing paint order. The host emits selection, composition and caret rectangles through that same interface; repainting multiple placements reuses the retained layout and glyph cache. -Glyph coverage uses quarter-pixel phases with the vertical sign converted to Swash's Y-up coordinates. The GPU readback test checks nine successive quarter-pixel translations by their linear-light ink centroids, then requires identical pixels on repaint, cache eviction and renderer reconstruction on the same device. The native caret experiment exposed the previous inverted vertical phase; correcting raster placement does not change line metrics or wrapping. +Glyph coverage uses quarter-pixel phases with the vertical sign converted to Swash's Y-up coordinates. The `draw` GPU readback test checks nine successive quarter-pixel translations by their linear-light ink centroids, then requires identical pixels on repaint, cache eviction and renderer reconstruction on the same device. The native caret experiment exposed the previous inverted vertical phase; correcting raster placement does not change line metrics or wrapping. Image textures have a separate 64 MiB / 256-entry cache. Offscreen images do not upload or consume the active-frame budget; a frame whose visible images exceed that budget returns an error. Under pressure, eviction preserves every image needed by the current frame. PNG/JPEG decoding checks encoded size, dimensions and decoded output size, with a best-effort codec allocation limit. These limits do not bound all CPU buffers or driver allocations. Images use linear filtering of premultiplied, linear-light colors; glyphs retain their own sampling/blending policy. Decoded color values are currently interpreted as sRGB; ICC and orientation metadata are not transformed by this experiment. -- 2.54.0