web engine - experimental web browser

Implement software renderer: paint layout tree to BGRA bitmap

- Color struct with BLACK/WHITE constants
- Display list with FillRect and DrawGlyphs paint commands
- Display list generation from layout tree (painter's order)
- Software renderer: BGRA pixel buffer with rect filling and glyph compositing
- Source-over alpha blending for anti-aliased text rendering
- 11 tests covering backgrounds, text rendering, clipping, BGRA format

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+538
+538
crates/render/src/lib.rs
··· 1 1 //! Display list, software rasterizer, Metal GPU compositor. 2 + //! 3 + //! Walks a layout tree, generates paint commands, and rasterizes them 4 + //! into a BGRA pixel buffer suitable for display via CoreGraphics. 5 + 6 + use we_layout::{BoxType, LayoutBox, LayoutTree, TextLine}; 7 + use we_text::font::Font; 8 + 9 + /// An RGBA color with 8-bit components. 10 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 11 + pub struct Color { 12 + pub r: u8, 13 + pub g: u8, 14 + pub b: u8, 15 + pub a: u8, 16 + } 17 + 18 + impl Color { 19 + pub const BLACK: Color = Color { 20 + r: 0, 21 + g: 0, 22 + b: 0, 23 + a: 255, 24 + }; 25 + pub const WHITE: Color = Color { 26 + r: 255, 27 + g: 255, 28 + b: 255, 29 + a: 255, 30 + }; 31 + } 32 + 33 + /// A paint command in the display list. 34 + #[derive(Debug)] 35 + pub enum PaintCommand { 36 + /// Fill a rectangle with a solid color. 37 + FillRect { 38 + x: f32, 39 + y: f32, 40 + width: f32, 41 + height: f32, 42 + color: Color, 43 + }, 44 + /// Draw a glyph bitmap at a position with a text color. 45 + DrawGlyphs { 46 + line: TextLine, 47 + font_size: f32, 48 + color: Color, 49 + }, 50 + } 51 + 52 + /// A flat list of paint commands in painter's order. 53 + pub type DisplayList = Vec<PaintCommand>; 54 + 55 + /// Build a display list from a layout tree. 56 + /// 57 + /// Walks the tree in depth-first pre-order (painter's order): 58 + /// backgrounds first, then text on top. 59 + pub fn build_display_list(tree: &LayoutTree) -> DisplayList { 60 + let mut list = DisplayList::new(); 61 + paint_box(&tree.root, &mut list); 62 + list 63 + } 64 + 65 + fn paint_box(layout_box: &LayoutBox, list: &mut DisplayList) { 66 + // Paint background for block-level boxes. 67 + paint_background(layout_box, list); 68 + 69 + // Paint text lines (inline content). 70 + paint_text(layout_box, list); 71 + 72 + // Recurse into children. 73 + for child in &layout_box.children { 74 + paint_box(child, list); 75 + } 76 + } 77 + 78 + fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList) { 79 + match &layout_box.box_type { 80 + BoxType::Block(_) | BoxType::Anonymous => { 81 + // Paint a white background for block boxes. 82 + // Only emit background if the box has non-zero area. 83 + if layout_box.rect.width > 0.0 && layout_box.rect.height > 0.0 { 84 + list.push(PaintCommand::FillRect { 85 + x: layout_box.rect.x, 86 + y: layout_box.rect.y, 87 + width: layout_box.rect.width, 88 + height: layout_box.rect.height, 89 + color: Color::WHITE, 90 + }); 91 + } 92 + } 93 + BoxType::Inline(_) | BoxType::TextRun { .. } => {} 94 + } 95 + } 96 + 97 + fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { 98 + for line in &layout_box.lines { 99 + list.push(PaintCommand::DrawGlyphs { 100 + line: line.clone(), 101 + font_size: layout_box.font_size, 102 + color: Color::BLACK, 103 + }); 104 + } 105 + } 106 + 107 + /// Software renderer that paints a display list into a BGRA pixel buffer. 108 + pub struct Renderer { 109 + width: u32, 110 + height: u32, 111 + /// BGRA pixel data, row-major, top-to-bottom. 112 + buffer: Vec<u8>, 113 + } 114 + 115 + impl Renderer { 116 + /// Create a new renderer with the given dimensions. 117 + /// The buffer is initialized to white. 118 + pub fn new(width: u32, height: u32) -> Renderer { 119 + let size = (width as usize) * (height as usize) * 4; 120 + let mut buffer = vec![0u8; size]; 121 + // Fill with white (BGRA: B=255, G=255, R=255, A=255). 122 + for pixel in buffer.chunks_exact_mut(4) { 123 + pixel[0] = 255; // B 124 + pixel[1] = 255; // G 125 + pixel[2] = 255; // R 126 + pixel[3] = 255; // A 127 + } 128 + Renderer { 129 + width, 130 + height, 131 + buffer, 132 + } 133 + } 134 + 135 + /// Paint a layout tree into the pixel buffer. 136 + pub fn paint(&mut self, layout_tree: &LayoutTree, font: &Font) { 137 + let display_list = build_display_list(layout_tree); 138 + for cmd in &display_list { 139 + match cmd { 140 + PaintCommand::FillRect { 141 + x, 142 + y, 143 + width, 144 + height, 145 + color, 146 + } => { 147 + self.fill_rect(*x, *y, *width, *height, *color); 148 + } 149 + PaintCommand::DrawGlyphs { 150 + line, 151 + font_size, 152 + color, 153 + } => { 154 + self.draw_text_line(line, *font_size, *color, font); 155 + } 156 + } 157 + } 158 + } 159 + 160 + /// Get the BGRA pixel data. 161 + pub fn pixels(&self) -> &[u8] { 162 + &self.buffer 163 + } 164 + 165 + /// Width in pixels. 166 + pub fn width(&self) -> u32 { 167 + self.width 168 + } 169 + 170 + /// Height in pixels. 171 + pub fn height(&self) -> u32 { 172 + self.height 173 + } 174 + 175 + /// Fill a rectangle with a solid color. 176 + fn fill_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Color) { 177 + let x0 = (x as i32).max(0) as u32; 178 + let y0 = (y as i32).max(0) as u32; 179 + let x1 = ((x + width) as i32).max(0).min(self.width as i32) as u32; 180 + let y1 = ((y + height) as i32).max(0).min(self.height as i32) as u32; 181 + 182 + for py in y0..y1 { 183 + for px in x0..x1 { 184 + self.set_pixel(px, py, color); 185 + } 186 + } 187 + } 188 + 189 + /// Draw a line of text using the font to render glyphs. 190 + fn draw_text_line(&mut self, line: &TextLine, font_size: f32, color: Color, font: &Font) { 191 + let positioned = font.render_text(&line.text, font_size); 192 + 193 + for glyph in &positioned { 194 + if let Some(ref bitmap) = glyph.bitmap { 195 + // Glyph origin: line position + glyph horizontal offset. 196 + let gx = line.x + glyph.x + bitmap.bearing_x as f32; 197 + // Baseline is at line.y + font_size (approximate: baseline at ~80% of font_size). 198 + // bearing_y is distance from baseline to top of glyph (positive = above baseline). 199 + let baseline_y = line.y + font_size; 200 + let gy = baseline_y - bitmap.bearing_y as f32; 201 + 202 + self.composite_glyph(gx, gy, bitmap, color); 203 + } 204 + } 205 + } 206 + 207 + /// Composite a grayscale glyph bitmap onto the buffer with anti-aliasing. 208 + fn composite_glyph( 209 + &mut self, 210 + x: f32, 211 + y: f32, 212 + bitmap: &we_text::font::rasterizer::GlyphBitmap, 213 + color: Color, 214 + ) { 215 + let x0 = x as i32; 216 + let y0 = y as i32; 217 + 218 + for by in 0..bitmap.height { 219 + for bx in 0..bitmap.width { 220 + let px = x0 + bx as i32; 221 + let py = y0 + by as i32; 222 + 223 + if px < 0 || py < 0 || px >= self.width as i32 || py >= self.height as i32 { 224 + continue; 225 + } 226 + 227 + let coverage = bitmap.data[(by * bitmap.width + bx) as usize]; 228 + if coverage == 0 { 229 + continue; 230 + } 231 + 232 + let px = px as u32; 233 + let py = py as u32; 234 + 235 + // Alpha-blend the glyph coverage with the text color onto the background. 236 + let alpha = (coverage as u32 * color.a as u32) / 255; 237 + if alpha == 0 { 238 + continue; 239 + } 240 + 241 + let offset = ((py * self.width + px) * 4) as usize; 242 + let dst_b = self.buffer[offset] as u32; 243 + let dst_g = self.buffer[offset + 1] as u32; 244 + let dst_r = self.buffer[offset + 2] as u32; 245 + 246 + // Source-over compositing. 247 + let inv_alpha = 255 - alpha; 248 + self.buffer[offset] = ((color.b as u32 * alpha + dst_b * inv_alpha) / 255) as u8; 249 + self.buffer[offset + 1] = 250 + ((color.g as u32 * alpha + dst_g * inv_alpha) / 255) as u8; 251 + self.buffer[offset + 2] = 252 + ((color.r as u32 * alpha + dst_r * inv_alpha) / 255) as u8; 253 + self.buffer[offset + 3] = 255; // Fully opaque. 254 + } 255 + } 256 + } 257 + 258 + /// Set a single pixel to the given color (no blending). 259 + fn set_pixel(&mut self, x: u32, y: u32, color: Color) { 260 + if x >= self.width || y >= self.height { 261 + return; 262 + } 263 + let offset = ((y * self.width + x) * 4) as usize; 264 + self.buffer[offset] = color.b; // B 265 + self.buffer[offset + 1] = color.g; // G 266 + self.buffer[offset + 2] = color.r; // R 267 + self.buffer[offset + 3] = color.a; // A 268 + } 269 + } 270 + 271 + #[cfg(test)] 272 + mod tests { 273 + use super::*; 274 + use we_dom::Document; 275 + use we_text::font::Font; 276 + 277 + fn test_font() -> Font { 278 + let paths = [ 279 + "/System/Library/Fonts/Geneva.ttf", 280 + "/System/Library/Fonts/Monaco.ttf", 281 + ]; 282 + for path in &paths { 283 + let p = std::path::Path::new(path); 284 + if p.exists() { 285 + return Font::from_file(p).expect("failed to parse font"); 286 + } 287 + } 288 + panic!("no test font found"); 289 + } 290 + 291 + #[test] 292 + fn renderer_new_white_background() { 293 + let r = Renderer::new(10, 10); 294 + assert_eq!(r.width(), 10); 295 + assert_eq!(r.height(), 10); 296 + assert_eq!(r.pixels().len(), 10 * 10 * 4); 297 + // All pixels should be white (BGRA: 255, 255, 255, 255). 298 + for pixel in r.pixels().chunks_exact(4) { 299 + assert_eq!(pixel, [255, 255, 255, 255]); 300 + } 301 + } 302 + 303 + #[test] 304 + fn fill_rect_basic() { 305 + let mut r = Renderer::new(20, 20); 306 + let red = Color { 307 + r: 255, 308 + g: 0, 309 + b: 0, 310 + a: 255, 311 + }; 312 + r.fill_rect(5.0, 5.0, 10.0, 10.0, red); 313 + 314 + // Pixel at (7, 7) should be red (BGRA: 0, 0, 255, 255). 315 + let offset = ((7 * 20 + 7) * 4) as usize; 316 + assert_eq!(r.pixels()[offset], 0); // B 317 + assert_eq!(r.pixels()[offset + 1], 0); // G 318 + assert_eq!(r.pixels()[offset + 2], 255); // R 319 + assert_eq!(r.pixels()[offset + 3], 255); // A 320 + 321 + // Pixel at (0, 0) should still be white. 322 + assert_eq!(r.pixels()[0], 255); // B 323 + assert_eq!(r.pixels()[1], 255); // G 324 + assert_eq!(r.pixels()[2], 255); // R 325 + assert_eq!(r.pixels()[3], 255); // A 326 + } 327 + 328 + #[test] 329 + fn fill_rect_clipping() { 330 + let mut r = Renderer::new(10, 10); 331 + let blue = Color { 332 + r: 0, 333 + g: 0, 334 + b: 255, 335 + a: 255, 336 + }; 337 + // Rect extends beyond the buffer — should not panic. 338 + r.fill_rect(-5.0, -5.0, 20.0, 20.0, blue); 339 + 340 + // All pixels should be blue (BGRA: 255, 0, 0, 255). 341 + for pixel in r.pixels().chunks_exact(4) { 342 + assert_eq!(pixel, [255, 0, 0, 255]); 343 + } 344 + } 345 + 346 + #[test] 347 + fn color_constants() { 348 + assert_eq!( 349 + Color::BLACK, 350 + Color { 351 + r: 0, 352 + g: 0, 353 + b: 0, 354 + a: 255 355 + } 356 + ); 357 + assert_eq!( 358 + Color::WHITE, 359 + Color { 360 + r: 255, 361 + g: 255, 362 + b: 255, 363 + a: 255 364 + } 365 + ); 366 + } 367 + 368 + #[test] 369 + fn display_list_from_empty_layout() { 370 + let font = test_font(); 371 + let doc = Document::new(); 372 + let tree = we_layout::layout(&doc, 800.0, 600.0, &font); 373 + let list = build_display_list(&tree); 374 + // Empty document should produce no paint commands (or just a background). 375 + // Just check it doesn't panic. 376 + assert!(list.len() <= 1); 377 + } 378 + 379 + #[test] 380 + fn display_list_has_background_and_text() { 381 + let font = test_font(); 382 + let mut doc = Document::new(); 383 + let root = doc.root(); 384 + let html = doc.create_element("html"); 385 + let body = doc.create_element("body"); 386 + let p = doc.create_element("p"); 387 + let text = doc.create_text("Hello world"); 388 + doc.append_child(root, html); 389 + doc.append_child(html, body); 390 + doc.append_child(body, p); 391 + doc.append_child(p, text); 392 + 393 + let tree = we_layout::layout(&doc, 800.0, 600.0, &font); 394 + let list = build_display_list(&tree); 395 + 396 + let has_fill = list 397 + .iter() 398 + .any(|c| matches!(c, PaintCommand::FillRect { .. })); 399 + let has_text = list 400 + .iter() 401 + .any(|c| matches!(c, PaintCommand::DrawGlyphs { .. })); 402 + 403 + assert!(has_fill, "should have at least one FillRect"); 404 + assert!(has_text, "should have at least one DrawGlyphs"); 405 + } 406 + 407 + #[test] 408 + fn paint_simple_page() { 409 + let font = test_font(); 410 + let mut doc = Document::new(); 411 + let root = doc.root(); 412 + let html = doc.create_element("html"); 413 + let body = doc.create_element("body"); 414 + let p = doc.create_element("p"); 415 + let text = doc.create_text("Hello"); 416 + doc.append_child(root, html); 417 + doc.append_child(html, body); 418 + doc.append_child(body, p); 419 + doc.append_child(p, text); 420 + 421 + let tree = we_layout::layout(&doc, 800.0, 600.0, &font); 422 + let mut renderer = Renderer::new(800, 600); 423 + renderer.paint(&tree, &font); 424 + 425 + let pixels = renderer.pixels(); 426 + 427 + // The buffer should have some non-white pixels (from text rendering). 428 + let has_non_white = pixels.chunks_exact(4).any(|p| p != [255, 255, 255, 255]); 429 + assert!( 430 + has_non_white, 431 + "rendered page should have non-white pixels from text" 432 + ); 433 + } 434 + 435 + #[test] 436 + fn bgra_format_correct() { 437 + let mut r = Renderer::new(1, 1); 438 + let color = Color { 439 + r: 100, 440 + g: 150, 441 + b: 200, 442 + a: 255, 443 + }; 444 + r.set_pixel(0, 0, color); 445 + let pixels = r.pixels(); 446 + // BGRA format. 447 + assert_eq!(pixels[0], 200); // B 448 + assert_eq!(pixels[1], 150); // G 449 + assert_eq!(pixels[2], 100); // R 450 + assert_eq!(pixels[3], 255); // A 451 + } 452 + 453 + #[test] 454 + fn paint_heading_produces_larger_glyphs() { 455 + let font = test_font(); 456 + let mut doc = Document::new(); 457 + let root = doc.root(); 458 + let html = doc.create_element("html"); 459 + let body = doc.create_element("body"); 460 + let h1 = doc.create_element("h1"); 461 + let h1_text = doc.create_text("Big"); 462 + let p = doc.create_element("p"); 463 + let p_text = doc.create_text("Small"); 464 + doc.append_child(root, html); 465 + doc.append_child(html, body); 466 + doc.append_child(body, h1); 467 + doc.append_child(h1, h1_text); 468 + doc.append_child(body, p); 469 + doc.append_child(p, p_text); 470 + 471 + let tree = we_layout::layout(&doc, 800.0, 600.0, &font); 472 + let list = build_display_list(&tree); 473 + 474 + // There should be DrawGlyphs commands with different font sizes. 475 + let font_sizes: Vec<f32> = list 476 + .iter() 477 + .filter_map(|c| match c { 478 + PaintCommand::DrawGlyphs { font_size, .. } => Some(*font_size), 479 + _ => None, 480 + }) 481 + .collect(); 482 + 483 + assert!(font_sizes.len() >= 2, "should have at least 2 text lines"); 484 + // h1 has font_size 32, p has font_size 16. 485 + assert!( 486 + font_sizes.iter().any(|&s| s > 20.0), 487 + "should have a heading with large font size" 488 + ); 489 + assert!( 490 + font_sizes.iter().any(|&s| s < 20.0), 491 + "should have a paragraph with normal font size" 492 + ); 493 + } 494 + 495 + #[test] 496 + fn renderer_zero_size() { 497 + let r = Renderer::new(0, 0); 498 + assert_eq!(r.pixels().len(), 0); 499 + } 500 + 501 + #[test] 502 + fn glyph_compositing_anti_aliased() { 503 + // Render text and verify we get anti-aliased (partially transparent) pixels. 504 + let font = test_font(); 505 + let mut doc = Document::new(); 506 + let root = doc.root(); 507 + let html = doc.create_element("html"); 508 + let body = doc.create_element("body"); 509 + let p = doc.create_element("p"); 510 + let text = doc.create_text("MMMMM"); 511 + doc.append_child(root, html); 512 + doc.append_child(html, body); 513 + doc.append_child(body, p); 514 + doc.append_child(p, text); 515 + 516 + let tree = we_layout::layout(&doc, 800.0, 600.0, &font); 517 + let mut renderer = Renderer::new(800, 600); 518 + renderer.paint(&tree, &font); 519 + 520 + let pixels = renderer.pixels(); 521 + // Find pixels that are not pure white and not pure black. 522 + // These represent anti-aliased edges of glyphs. 523 + let mut has_intermediate = false; 524 + for pixel in pixels.chunks_exact(4) { 525 + let b = pixel[0]; 526 + let g = pixel[1]; 527 + let r = pixel[2]; 528 + // Anti-aliased pixel: gray between white and black. 529 + if r > 0 && r < 255 && r == g && g == b { 530 + has_intermediate = true; 531 + break; 532 + } 533 + } 534 + assert!( 535 + has_intermediate, 536 + "should have anti-aliased (gray) pixels from glyph compositing" 537 + ); 538 + } 539 + }