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/canvas.wgsl b/crates/canvas/src/gpu/canvas.wgsl deleted file mode 100644 index 46e482110fb6f54c8f07e38c10edb9db36351f91..0000000000000000000000000000000000000000 --- a/crates/canvas/src/gpu/canvas.wgsl +++ /dev/null @@ -1,87 +0,0 @@ -struct Vertex { - @builtin(position) position: vec4, - @location(0) uv: vec2, - @location(1) color: vec4, - @location(2) local: vec2, - @location(3) @interpolate(flat) shape: vec4, - @location(4) @interpolate(flat) stroke: f32, -} - -@group(0) @binding(0) var atlas: texture_2d; -@group(0) @binding(1) var atlas_sampler: sampler; - -@vertex -fn vertex(@location(0) position: vec2, @location(1) uv: vec2, - @location(2) color: vec4, @location(3) local: vec2, - @location(4) shape: vec4, @location(5) stroke: f32) -> Vertex { - return Vertex(vec4(position, 0.0, 1.0), uv, color, local, shape, stroke); -} - -fn ellipse_arc(angle: f32, radius: vec2) -> f32 { - if radius.x == radius.y { - return angle * radius.x; - } - // Four-point Gauss-Legendre quadrature. - let nodes = vec4(-0.86113631, -0.33998104, 0.33998104, 0.86113631); - let weights = vec4(0.34785485, 0.65214515, 0.65214515, 0.34785485); - let half = angle * 0.5; - let scale = max(radius.x, radius.y); - var sum = 0.0; - for (var i = 0u; i < 4u; i += 1u) { - let t = half * (nodes[i] + 1.0); - sum += weights[i] * length((radius / scale) * vec2(sin(t), cos(t))); - } - return half * sum * scale; -} - -@fragment -fn fragment(input: Vertex) -> @location(0) vec4 { - var color = textureSample(atlas, atlas_sampler, input.uv) * input.color; - if input.shape.x > 0.0 && input.shape.y > 0.0 { - let q = abs(input.local) - input.shape.xy + input.shape.zw; - var distance: f32; - if input.shape.z == input.shape.w { - distance = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - input.shape.z; - } else if all(q > vec2(0.0)) { - let radius = max(input.shape.zw, vec2(0.0001)); - let normalized = q / radius; - let k0 = length(normalized); - distance = k0 * (k0 - 1.0) / max(length(normalized / radius), 1e-20); - } else { - let edge = abs(input.local) - input.shape.xy; - distance = max(edge.x, edge.y); - } - var coverage = clamp(0.5 - distance, 0.0, 1.0); - let width = abs(input.stroke); - if width > 0.0 { - coverage *= clamp(distance + width + 0.5, 0.0, 1.0); - } - if input.stroke < 0.0 { - var radius = max(input.shape.zw - width * 0.5, vec2(0.0)); - if any(radius == vec2(0.0)) { - radius = vec2(0.0); - } - let straight = input.shape.xy - width * 0.5 - radius; - let p = abs(input.local); - let quarter = straight.x + straight.y + ellipse_arc(1.57079632679, radius); - var along: f32; - if p.y <= straight.y { - along = p.y; - } else if p.x <= straight.x || radius.x == 0.0 { - along = quarter - p.x; - } else { - let angle = atan2((p.y - straight.y) / radius.y, (p.x - straight.x) / radius.x); - along = straight.y + ellipse_arc(angle, radius); - } - if input.local.x < 0.0 { - along = select(2.0 * quarter - along, 2.0 * quarter + along, input.local.y < 0.0); - } else if input.local.y < 0.0 { - along = 4.0 * quarter - along; - } - let phase = fract(along / (4.0 * width)) * (4.0 * width); - coverage *= clamp(width + 0.5 - abs(phase - 2.0 * width), 0.0, 1.0); - } - color *= coverage; - } - return color; -} 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/gpu/tags.rs b/crates/canvas/src/gpu/tags.rs deleted file mode 100644 index 37038f6904e233460ecb9bfb7e8ae0793f7b90b9..0000000000000000000000000000000000000000 --- a/crates/canvas/src/gpu/tags.rs +++ /dev/null @@ -1,140 +0,0 @@ -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), - }; - 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"); - 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 - .attribute("fill") - .unwrap() - .strip_prefix("url(#") - .unwrap() - .strip_suffix(')') - .unwrap(); - let gradient = svg - .descendants() - .find(|node| node.attribute("id") == Some(gradient)) - .unwrap(); - let mut stops = gradient - .children() - .filter(|node| node.has_tag_name("stop")) - .map(|node| { - let color = u32::from_str_radix( - node.attribute("stop-color") - .unwrap() - .strip_prefix('#') - .unwrap(), - 16, - ) - .unwrap(); - let [_, r, g, b] = color.to_be_bytes(); - super::colorref(u32::from_le_bytes([r, g, b, 0])) - }); - let top = stops.next().unwrap(); - let bottom = stops.next().unwrap(); - assert!(stops.next().is_none()); - let (mask, _) = Mask::new(path.attribute("d").unwrap()) - .transform(Some(Transform::scale(size / 16.0, size / 16.0))) - .render_offset(Vector::new(phase[0] as f32 * 0.25, phase[1] as f32 * 0.25)) - .size(width, width) - .render(); - for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { - let alpha = f32::from(coverage) / 255.0; - let y = ((index / width as usize) as f32 / size).min(1.0); - for channel in 0..3 { - let color = top[channel] + (bottom[channel] - top[channel]) * y; - pixel[channel] = color * alpha + pixel[channel] * (1.0 - alpha); - } - pixel[3] = alpha + pixel[3] * (1.0 - alpha); - } - } - } - let data = pixels - .into_iter() - .flat_map(|pixel| { - let alpha = pixel[3]; - let channel = |value: f32| { - let value = if alpha > 0.0 { value / alpha } else { 0.0 }; - super::srgb_byte(value) - }; - [ - channel(pixel[0]), - channel(pixel[1]), - channel(pixel[2]), - (alpha * 255.0).round() as u8, - ] - }) - .collect(); - Image { - content: Content::Color, - placement: Placement { - left: 0, - top: 0, - width, - height: width, - }, - data, - ..Image::default() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tag_masks_keep_transparency_and_checked_state_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); - assert_eq!( - image.data.len(), - (image.placement.width * image.placement.height * 4) as usize - ); - assert!(image.data.chunks_exact(4).any(|p| p[3] > 0)); - assert!(image.data.chunks_exact(4).any(|p| p[3] < 255)); - assert!( - image - .data - .chunks_exact(4) - .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)); - } - } - } -} 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/draw/src/draw.wgsl b/crates/draw/src/draw.wgsl new file mode 100644 index 0000000000000000000000000000000000000000..46e482110fb6f54c8f07e38c10edb9db36351f91 --- /dev/null +++ b/crates/draw/src/draw.wgsl @@ -0,0 +1,87 @@ +struct Vertex { + @builtin(position) position: vec4, + @location(0) uv: vec2, + @location(1) color: vec4, + @location(2) local: vec2, + @location(3) @interpolate(flat) shape: vec4, + @location(4) @interpolate(flat) stroke: f32, +} + +@group(0) @binding(0) var atlas: texture_2d; +@group(0) @binding(1) var atlas_sampler: sampler; + +@vertex +fn vertex(@location(0) position: vec2, @location(1) uv: vec2, + @location(2) color: vec4, @location(3) local: vec2, + @location(4) shape: vec4, @location(5) stroke: f32) -> Vertex { + return Vertex(vec4(position, 0.0, 1.0), uv, color, local, shape, stroke); +} + +fn ellipse_arc(angle: f32, radius: vec2) -> f32 { + if radius.x == radius.y { + return angle * radius.x; + } + // Four-point Gauss-Legendre quadrature. + let nodes = vec4(-0.86113631, -0.33998104, 0.33998104, 0.86113631); + let weights = vec4(0.34785485, 0.65214515, 0.65214515, 0.34785485); + let half = angle * 0.5; + let scale = max(radius.x, radius.y); + var sum = 0.0; + for (var i = 0u; i < 4u; i += 1u) { + let t = half * (nodes[i] + 1.0); + sum += weights[i] * length((radius / scale) * vec2(sin(t), cos(t))); + } + return half * sum * scale; +} + +@fragment +fn fragment(input: Vertex) -> @location(0) vec4 { + var color = textureSample(atlas, atlas_sampler, input.uv) * input.color; + if input.shape.x > 0.0 && input.shape.y > 0.0 { + let q = abs(input.local) - input.shape.xy + input.shape.zw; + var distance: f32; + if input.shape.z == input.shape.w { + distance = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - input.shape.z; + } else if all(q > vec2(0.0)) { + let radius = max(input.shape.zw, vec2(0.0001)); + let normalized = q / radius; + let k0 = length(normalized); + distance = k0 * (k0 - 1.0) / max(length(normalized / radius), 1e-20); + } else { + let edge = abs(input.local) - input.shape.xy; + distance = max(edge.x, edge.y); + } + var coverage = clamp(0.5 - distance, 0.0, 1.0); + let width = abs(input.stroke); + if width > 0.0 { + coverage *= clamp(distance + width + 0.5, 0.0, 1.0); + } + if input.stroke < 0.0 { + var radius = max(input.shape.zw - width * 0.5, vec2(0.0)); + if any(radius == vec2(0.0)) { + radius = vec2(0.0); + } + let straight = input.shape.xy - width * 0.5 - radius; + let p = abs(input.local); + let quarter = straight.x + straight.y + ellipse_arc(1.57079632679, radius); + var along: f32; + if p.y <= straight.y { + along = p.y; + } else if p.x <= straight.x || radius.x == 0.0 { + along = quarter - p.x; + } else { + let angle = atan2((p.y - straight.y) / radius.y, (p.x - straight.x) / radius.x); + along = straight.y + ellipse_arc(angle, radius); + } + if input.local.x < 0.0 { + along = select(2.0 * quarter - along, 2.0 * quarter + along, input.local.y < 0.0); + } else if input.local.y < 0.0 { + along = 4.0 * quarter - along; + } + let phase = fract(along / (4.0 * width)) * (4.0 * width); + coverage *= clamp(width + 0.5 - abs(phase - 2.0 * width), 0.0, 1.0); + } + color *= coverage; + } + return color; +} diff --git a/crates/draw/src/icon.rs b/crates/draw/src/icon.rs new file mode 100644 index 0000000000000000000000000000000000000000..f6d9fd43332c8894b5c4f3a92336ee5c12e24019 --- /dev/null +++ b/crates/draw/src/icon.rs @@ -0,0 +1,122 @@ +use swash::{ + scale::image::{Content, Image}, + zeno::{Mask, Placement, Transform, Vector}, +}; + +/// 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 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 + .attribute("fill") + .unwrap() + .strip_prefix("url(#") + .unwrap() + .strip_suffix(')') + .unwrap(); + let gradient = svg + .descendants() + .find(|node| node.attribute("id") == Some(gradient)) + .unwrap(); + let mut stops = gradient + .children() + .filter(|node| node.has_tag_name("stop")) + .map(|node| { + let color = u32::from_str_radix( + node.attribute("stop-color") + .unwrap() + .strip_prefix('#') + .unwrap(), + 16, + ) + .unwrap(); + let [_, r, g, b] = color.to_be_bytes(); + super::srgb(r, g, b) + }); + let top = stops.next().unwrap(); + let bottom = stops.next().unwrap(); + assert!(stops.next().is_none()); + let (mask, _) = Mask::new(path.attribute("d").unwrap()) + .transform(Some(Transform::scale(size / 16.0, size / 16.0))) + .render_offset(Vector::new(phase[0] as f32 * 0.25, phase[1] as f32 * 0.25)) + .size(width, width) + .render(); + for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { + let alpha = f32::from(coverage) / 255.0; + let y = ((index / width as usize) as f32 / size).min(1.0); + for channel in 0..3 { + let color = top[channel] + (bottom[channel] - top[channel]) * y; + pixel[channel] = color * alpha + pixel[channel] * (1.0 - alpha); + } + pixel[3] = alpha + pixel[3] * (1.0 - alpha); + } + } + } + let data = pixels + .into_iter() + .flat_map(|pixel| { + let alpha = pixel[3]; + let channel = |value: f32| { + let value = if alpha > 0.0 { value / alpha } else { 0.0 }; + super::srgb_byte(value) + }; + [ + channel(pixel[0]), + channel(pixel[1]), + channel(pixel[2]), + (alpha * 255.0).round() as u8, + ] + }) + .collect(); + Image { + content: Content::Color, + placement: Placement { + left: 0, + top: 0, + width, + height: width, + }, + data, + ..Image::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BOX: &str = r##""##; + const MARK: &str = r##""##; + + #[test] + 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 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 + ); + assert!(image.data.chunks_exact(4).any(|p| p[3] > 0)); + assert!(image.data.chunks_exact(4).any(|p| p[3] < 255)); + assert!( + image + .data + .chunks_exact(4) + .filter(|p| p[3] == 0) + .all(|p| p[..3] == [0; 3]) + ); + } + 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.