we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement display list to Metal render passes

Add GpuRenderer that translates the DisplayList into batched Metal GPU
draw calls, replacing the software renderer when Metal is available.

- FillRect → solid-color quads via dummy 1x1 white texture
- DrawGlyphs → textured quads sampling from glyph atlas pages
- DrawImage → textured quads from per-node image textures
- PushClip/PopClip → Metal scissor rects with clip stack
- Batches consecutive same-texture draws into single draw calls
- Flushes batches at texture and clip boundary changes

Platform changes:
- Expose MetalView layer/queue/clear_color accessors
- Add RenderCommandEncoder::set_scissor_rect
- Make MTL_PRIMITIVE_TYPE_TRIANGLE and MetalRenderer accessors public

Browser integration:
- GPU path: style → layout → display list → Metal draw (no bitmap copy)
- Software fallback preserved when Metal unavailable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+990 -45
+155 -44
crates/browser/src/main.rs
··· 12 12 use we_layout::layout; 13 13 use we_platform::appkit; 14 14 use we_platform::cg::BitmapContext; 15 - use we_platform::metal::ClearColor; 16 - use we_render::{Renderer, ScrollState}; 15 + use we_platform::metal::{ClearColor, Device}; 16 + use we_render::gpu::GpuRenderer; 17 + use we_render::{build_display_list_with_page_scroll, Renderer, ScrollState}; 17 18 use we_style::computed::resolve_styles; 18 19 use we_text::font::{self, Font}; 19 20 use we_url::Url; ··· 37 38 font: Font, 38 39 bitmap: Box<BitmapContext>, 39 40 view: ViewKind, 41 + /// GPU renderer (only present when Metal is available). 42 + gpu_renderer: Option<GpuRenderer>, 40 43 /// Page-level scroll offset (vertical). 41 44 page_scroll_y: f32, 42 45 /// Total content height from the last layout (for scroll clamping). ··· 136 139 tree.root.content_height 137 140 } 138 141 142 + /// GPU rendering path: resolve styles → layout → build display list → Metal draw. 143 + /// 144 + /// Returns the total content height for scroll clamping. 145 + #[allow(clippy::too_many_arguments)] 146 + fn render_page_gpu( 147 + page: &PageState, 148 + font: &Font, 149 + gpu: &mut GpuRenderer, 150 + metal_view: &appkit::MetalView, 151 + viewport_width: f32, 152 + viewport_height: f32, 153 + page_scroll_y: f32, 154 + scroll_offsets: &ScrollState, 155 + ) -> f32 { 156 + let width = viewport_width as u32; 157 + let height = viewport_height as u32; 158 + if width == 0 || height == 0 { 159 + return 0.0; 160 + } 161 + 162 + // Resolve computed styles from DOM + stylesheet. 163 + let styled = match resolve_styles( 164 + &page.doc, 165 + std::slice::from_ref(&page.stylesheet), 166 + (viewport_width, viewport_height), 167 + ) { 168 + Some(s) => s, 169 + None => return 0.0, 170 + }; 171 + 172 + // Build image maps for layout (sizes) and render (pixel data). 173 + let sizes = image_sizes(&page.images); 174 + let refs = image_refs(&page.images); 175 + 176 + // Layout. 177 + let tree = layout( 178 + &styled, 179 + &page.doc, 180 + viewport_width, 181 + viewport_height, 182 + font, 183 + &sizes, 184 + ); 185 + 186 + // Build display list with scroll state. 187 + let display_list = build_display_list_with_page_scroll(&tree, page_scroll_y, scroll_offsets); 188 + 189 + // Render via Metal GPU. 190 + gpu.render( 191 + &display_list, 192 + font, 193 + &refs, 194 + metal_view.layer(), 195 + metal_view.command_queue(), 196 + metal_view.clear_color(), 197 + viewport_width, 198 + viewport_height, 199 + ); 200 + 201 + tree.root.content_height 202 + } 203 + 139 204 /// Called by the platform crate when the window is resized. 140 205 fn handle_resize(width: f64, height: f64) { 141 206 STATE.with(|state| { ··· 151 216 return; 152 217 } 153 218 154 - // Create a new bitmap context with the new dimensions. 155 - let mut new_bitmap = match BitmapContext::new(w, h) { 156 - Some(b) => Box::new(b), 157 - None => return, 158 - }; 219 + match (&state.view, &mut state.gpu_renderer) { 220 + (ViewKind::Metal(metal_view), Some(gpu)) => { 221 + metal_view.update_drawable_size(width, height); 222 + let content_height = render_page_gpu( 223 + &state.page, 224 + &state.font, 225 + gpu, 226 + metal_view, 227 + width as f32, 228 + height as f32, 229 + state.page_scroll_y, 230 + &state.scroll_offsets, 231 + ); 232 + state.content_height = content_height; 159 233 160 - let content_height = render_page( 161 - &state.page, 162 - &state.font, 163 - &mut new_bitmap, 164 - state.page_scroll_y, 165 - &state.scroll_offsets, 166 - ); 167 - state.content_height = content_height; 234 + // Clamp scroll position after resize. 235 + let max_scroll = (state.content_height - height as f32).max(0.0); 236 + state.page_scroll_y = state.page_scroll_y.clamp(0.0, max_scroll); 237 + } 238 + _ => { 239 + // Software rendering fallback. 240 + let mut new_bitmap = match BitmapContext::new(w, h) { 241 + Some(b) => Box::new(b), 242 + None => return, 243 + }; 244 + let content_height = render_page( 245 + &state.page, 246 + &state.font, 247 + &mut new_bitmap, 248 + state.page_scroll_y, 249 + &state.scroll_offsets, 250 + ); 251 + state.content_height = content_height; 168 252 169 - // Clamp scroll position after resize (viewport may have grown). 170 - let viewport_height = h as f32; 171 - let max_scroll = (state.content_height - viewport_height).max(0.0); 172 - state.page_scroll_y = state.page_scroll_y.clamp(0.0, max_scroll); 253 + let max_scroll = (state.content_height - h as f32).max(0.0); 254 + state.page_scroll_y = state.page_scroll_y.clamp(0.0, max_scroll); 173 255 174 - // Swap in the new bitmap and update the view. 175 - state.bitmap = new_bitmap; 176 - match &state.view { 177 - ViewKind::Metal(metal_view) => { 178 - metal_view.update_drawable_size(width, height); 179 - metal_view.set_needs_display(); 180 - } 181 - ViewKind::Bitmap(bitmap_view) => { 182 - bitmap_view.update_bitmap(&state.bitmap); 256 + state.bitmap = new_bitmap; 257 + if let ViewKind::Bitmap(bitmap_view) = &state.view { 258 + bitmap_view.update_bitmap(&state.bitmap); 259 + } 183 260 } 184 261 } 185 262 }); ··· 200 277 // Apply scroll delta (negative dy = scroll down). 201 278 state.page_scroll_y = (state.page_scroll_y - dy as f32).clamp(0.0, max_scroll); 202 279 203 - // Re-render with updated scroll position. 204 - let content_height = render_page( 205 - &state.page, 206 - &state.font, 207 - &mut state.bitmap, 208 - state.page_scroll_y, 209 - &state.scroll_offsets, 210 - ); 211 - state.content_height = content_height; 212 - match &state.view { 213 - ViewKind::Metal(metal_view) => metal_view.set_needs_display(), 214 - ViewKind::Bitmap(bitmap_view) => bitmap_view.set_needs_display(), 280 + match (&state.view, &mut state.gpu_renderer) { 281 + (ViewKind::Metal(metal_view), Some(gpu)) => { 282 + let content_height = render_page_gpu( 283 + &state.page, 284 + &state.font, 285 + gpu, 286 + metal_view, 287 + state.bitmap.width() as f32, 288 + state.bitmap.height() as f32, 289 + state.page_scroll_y, 290 + &state.scroll_offsets, 291 + ); 292 + state.content_height = content_height; 293 + } 294 + _ => { 295 + let content_height = render_page( 296 + &state.page, 297 + &state.font, 298 + &mut state.bitmap, 299 + state.page_scroll_y, 300 + &state.scroll_offsets, 301 + ); 302 + state.content_height = content_height; 303 + if let ViewKind::Bitmap(bitmap_view) = &state.view { 304 + bitmap_view.set_needs_display(); 305 + } 306 + } 215 307 } 216 308 }); 217 309 } ··· 357 449 let mut bitmap = 358 450 Box::new(BitmapContext::new(800, 600).expect("failed to create bitmap context")); 359 451 let scroll_offsets: HashMap<NodeId, (f32, f32)> = HashMap::new(); 360 - let content_height = render_page(&page, &font, &mut bitmap, 0.0, &scroll_offsets); 361 452 362 453 // Create the view — try Metal first, fall back to software bitmap. 363 454 let frame = appkit::NSRect::new(0.0, 0.0, 800.0, 600.0); 364 455 let clear_color = ClearColor::new(1.0, 1.0, 1.0, 1.0); // white background 365 - let view = match appkit::MetalView::new(frame, clear_color) { 456 + 457 + let (view, mut gpu_renderer) = match appkit::MetalView::new(frame, clear_color) { 366 458 Some(metal_view) => { 367 459 window.set_content_view(&metal_view.id()); 368 - ViewKind::Metal(metal_view) 460 + 461 + // Create GPU renderer on the same Metal device. 462 + let gpu = Device::system_default().and_then(GpuRenderer::new); 463 + (ViewKind::Metal(metal_view), gpu) 369 464 } 370 465 None => { 371 466 eprintln!("Metal not available, falling back to software rendering"); 372 467 let bitmap_view = appkit::BitmapView::new(frame, &bitmap); 373 468 window.set_content_view(&bitmap_view.id()); 374 - ViewKind::Bitmap(bitmap_view) 469 + (ViewKind::Bitmap(bitmap_view), None) 375 470 } 376 471 }; 377 472 473 + // Initial render. 474 + let content_height = match (&view, &mut gpu_renderer) { 475 + (ViewKind::Metal(metal_view), Some(gpu)) => render_page_gpu( 476 + &page, 477 + &font, 478 + gpu, 479 + metal_view, 480 + 800.0, 481 + 600.0, 482 + 0.0, 483 + &scroll_offsets, 484 + ), 485 + _ => render_page(&page, &font, &mut bitmap, 0.0, &scroll_offsets), 486 + }; 487 + 378 488 // Store state for the resize handler. 379 489 STATE.with(|state| { 380 490 *state.borrow_mut() = Some(BrowserState { ··· 382 492 font, 383 493 bitmap, 384 494 view, 495 + gpu_renderer, 385 496 page_scroll_y: 0.0, 386 497 content_height, 387 498 scroll_offsets,
+15
crates/platform/src/appkit.rs
··· 735 735 pub fn id(&self) -> Id { 736 736 self.view 737 737 } 738 + 739 + /// Access the Metal command queue for encoding GPU work. 740 + pub fn command_queue(&self) -> &CommandQueue { 741 + &self.state.queue 742 + } 743 + 744 + /// Access the CAMetalLayer for getting drawables. 745 + pub fn layer(&self) -> &MetalLayer { 746 + &self.state.layer 747 + } 748 + 749 + /// Access the clear color. 750 + pub fn clear_color(&self) -> ClearColor { 751 + self.state.clear_color 752 + } 738 753 } 739 754 740 755 // ---------------------------------------------------------------------------
+38 -1
crates/platform/src/metal.rs
··· 92 92 // --------------------------------------------------------------------------- 93 93 94 94 /// `MTLPrimitiveTypeTriangle` — render triangles from triples of vertices. 95 - const MTL_PRIMITIVE_TYPE_TRIANGLE: u64 = 3; 95 + pub const MTL_PRIMITIVE_TYPE_TRIANGLE: u64 = 3; 96 96 97 97 // --------------------------------------------------------------------------- 98 98 // MTLBlendFactor constants ··· 775 775 ]; 776 776 } 777 777 778 + /// Set the scissor rectangle for clipping rendered output. 779 + /// 780 + /// The rectangle is specified in pixel coordinates. Pixels outside the 781 + /// scissor rect are discarded. 782 + pub fn set_scissor_rect(&self, x: u64, y: u64, width: u64, height: u64) { 783 + // MTLScissorRect is { uint64_t x, y, width, height }. 784 + #[repr(C)] 785 + struct ScissorRect { 786 + x: u64, 787 + y: u64, 788 + width: u64, 789 + height: u64, 790 + } 791 + let rect = ScissorRect { 792 + x, 793 + y, 794 + width, 795 + height, 796 + }; 797 + let _: *mut c_void = msg_send![self.id.as_ptr(), setScissorRect: rect]; 798 + } 799 + 778 800 /// End encoding commands. 779 801 pub fn end_encoding(&self) { 780 802 let _: *mut c_void = msg_send![self.id.as_ptr(), endEncoding]; ··· 1011 1033 /// Return a reference to the underlying device. 1012 1034 pub fn device(&self) -> &Device { 1013 1035 &self.device 1036 + } 1037 + 1038 + /// Return a reference to the render pipeline state. 1039 + pub fn pipeline(&self) -> &RenderPipeline { 1040 + &self.pipeline 1041 + } 1042 + 1043 + /// Return a reference to the dummy 1×1 white texture. 1044 + pub fn dummy_texture(&self) -> &Texture { 1045 + &self.dummy_texture 1046 + } 1047 + 1048 + /// Return a reference to the linear-filtered sampler. 1049 + pub fn sampler(&self) -> &SamplerState { 1050 + &self.sampler 1014 1051 } 1015 1052 } 1016 1053
+781
crates/render/src/gpu.rs
··· 1 + //! Metal GPU renderer: translates display list paint commands into batched 2 + //! Metal draw calls for hardware-accelerated rendering. 3 + //! 4 + //! Replaces the software `Renderer` path when Metal is available. Walks the 5 + //! `DisplayList` in painter's order, converts each `PaintCommand` into GPU 6 + //! vertex data, and submits batched draw calls via the Metal render pipeline. 7 + 8 + use std::collections::HashMap; 9 + 10 + use we_css::values::Color; 11 + use we_dom::NodeId; 12 + use we_image::pixel::Image; 13 + use we_platform::metal::{ 14 + self, ClearColor, CommandQueue, Device, MetalLayer, MetalRenderer, RenderCommandEncoder, 15 + SamplerState, Texture, TextureCache, Uniforms, Vertex, MTL_PIXEL_FORMAT_BGRA8_UNORM, 16 + MTL_PRIMITIVE_TYPE_TRIANGLE, 17 + }; 18 + use we_text::font::Font; 19 + 20 + use crate::atlas::{GlyphAtlas, TexturedQuad}; 21 + use crate::{DisplayList, PaintCommand}; 22 + 23 + // --------------------------------------------------------------------------- 24 + // Clip rect (integer, for Metal scissor rects) 25 + // --------------------------------------------------------------------------- 26 + 27 + #[derive(Debug, Clone, Copy)] 28 + struct ScissorRect { 29 + x: u32, 30 + y: u32, 31 + width: u32, 32 + height: u32, 33 + } 34 + 35 + impl ScissorRect { 36 + fn full(viewport_width: u32, viewport_height: u32) -> Self { 37 + ScissorRect { 38 + x: 0, 39 + y: 0, 40 + width: viewport_width, 41 + height: viewport_height, 42 + } 43 + } 44 + 45 + fn intersect(self, other: ScissorRect) -> ScissorRect { 46 + let x0 = self.x.max(other.x); 47 + let y0 = self.y.max(other.y); 48 + let x1 = (self.x + self.width).min(other.x + other.width); 49 + let y1 = (self.y + self.height).min(other.y + other.height); 50 + if x1 <= x0 || y1 <= y0 { 51 + ScissorRect { 52 + x: 0, 53 + y: 0, 54 + width: 0, 55 + height: 0, 56 + } 57 + } else { 58 + ScissorRect { 59 + x: x0, 60 + y: y0, 61 + width: x1 - x0, 62 + height: y1 - y0, 63 + } 64 + } 65 + } 66 + } 67 + 68 + // --------------------------------------------------------------------------- 69 + // Texture key 70 + // --------------------------------------------------------------------------- 71 + 72 + /// Identifies which texture a batch uses. 73 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 74 + enum TextureKey { 75 + /// Solid-color quads — bind the 1x1 dummy white texture. 76 + Solid, 77 + /// Glyph atlas page. 78 + AtlasPage(usize), 79 + /// Image node texture. 80 + Image(usize), 81 + } 82 + 83 + // --------------------------------------------------------------------------- 84 + // GpuRenderer 85 + // --------------------------------------------------------------------------- 86 + 87 + /// Metal GPU renderer that processes a `DisplayList` into batched draw calls. 88 + /// 89 + /// Owns a glyph atlas and image texture cache to avoid redundant GPU uploads. 90 + pub struct GpuRenderer { 91 + renderer: MetalRenderer, 92 + atlas: GlyphAtlas, 93 + image_cache: TextureCache, 94 + /// GPU textures for each atlas page. 95 + atlas_textures: Vec<Option<Texture>>, 96 + /// Nearest-neighbor sampler for images. 97 + image_sampler: SamplerState, 98 + } 99 + 100 + impl GpuRenderer { 101 + /// Create a new GPU renderer on the given Metal device. 102 + /// 103 + /// Returns `None` if Metal pipeline creation fails. 104 + pub fn new(device: Device) -> Option<Self> { 105 + let image_sampler = device.new_sampler_state_nearest()?; 106 + let renderer = MetalRenderer::new(device)?; 107 + Some(GpuRenderer { 108 + renderer, 109 + atlas: GlyphAtlas::new(), 110 + image_cache: TextureCache::new(), 111 + atlas_textures: Vec::new(), 112 + image_sampler, 113 + }) 114 + } 115 + 116 + /// Render a display list to the Metal layer. 117 + /// 118 + /// Gets the next drawable from the layer, encodes all paint commands as 119 + /// batched GPU draw calls, and presents the result. 120 + #[allow(clippy::too_many_arguments)] 121 + pub fn render( 122 + &mut self, 123 + display_list: &DisplayList, 124 + font: &Font, 125 + images: &HashMap<NodeId, &Image>, 126 + layer: &MetalLayer, 127 + queue: &CommandQueue, 128 + clear_color: ClearColor, 129 + viewport_width: f32, 130 + viewport_height: f32, 131 + ) { 132 + let vw = viewport_width as u32; 133 + let vh = viewport_height as u32; 134 + if vw == 0 || vh == 0 { 135 + return; 136 + } 137 + 138 + // Get next drawable from the layer. 139 + let drawable = match layer.next_drawable() { 140 + Some(d) => d, 141 + None => return, 142 + }; 143 + let texture = match metal::drawable_texture(drawable) { 144 + Some(t) => t, 145 + None => return, 146 + }; 147 + 148 + // Create command buffer and render pass. 149 + let cmd_buf = match queue.command_buffer() { 150 + Some(b) => b, 151 + None => return, 152 + }; 153 + let desc = match metal::make_clear_pass_descriptor(texture, clear_color) { 154 + Some(d) => d, 155 + None => return, 156 + }; 157 + let encoder = match cmd_buf.render_command_encoder(desc) { 158 + Some(e) => e, 159 + None => return, 160 + }; 161 + 162 + // Set up pipeline state once. 163 + self.setup_encoder(&encoder, viewport_width, viewport_height); 164 + 165 + // Process display list into batched draw calls. 166 + self.encode_display_list( 167 + display_list, 168 + font, 169 + images, 170 + &encoder, 171 + vw, 172 + vh, 173 + viewport_width, 174 + viewport_height, 175 + ); 176 + 177 + encoder.end_encoding(); 178 + cmd_buf.present_drawable(drawable); 179 + cmd_buf.commit(); 180 + } 181 + 182 + /// Set up the render encoder with pipeline state and uniforms. 183 + fn setup_encoder( 184 + &self, 185 + encoder: &RenderCommandEncoder, 186 + viewport_width: f32, 187 + viewport_height: f32, 188 + ) { 189 + let uniforms = Uniforms { 190 + viewport_size: [viewport_width, viewport_height], 191 + }; 192 + encoder.set_render_pipeline_state(self.renderer.pipeline()); 193 + encoder.set_vertex_bytes(&uniforms, 1); 194 + encoder.set_fragment_sampler_state(self.renderer.sampler(), 0); 195 + } 196 + 197 + /// Process the display list and encode draw calls. 198 + #[allow(clippy::too_many_arguments)] 199 + fn encode_display_list( 200 + &mut self, 201 + display_list: &DisplayList, 202 + font: &Font, 203 + images: &HashMap<NodeId, &Image>, 204 + encoder: &RenderCommandEncoder, 205 + vw: u32, 206 + vh: u32, 207 + viewport_width: f32, 208 + viewport_height: f32, 209 + ) { 210 + let full_scissor = ScissorRect::full(vw, vh); 211 + let mut clip_stack: Vec<ScissorRect> = vec![full_scissor]; 212 + let mut current_scissor = full_scissor; 213 + 214 + // Current batch of vertices and their texture. 215 + let mut batch_vertices: Vec<Vertex> = Vec::with_capacity(4096); 216 + let mut batch_texture: TextureKey = TextureKey::Solid; 217 + 218 + for cmd in display_list { 219 + match cmd { 220 + PaintCommand::FillRect { 221 + x, 222 + y, 223 + width, 224 + height, 225 + color, 226 + } => { 227 + let tex_key = TextureKey::Solid; 228 + if tex_key != batch_texture && !batch_vertices.is_empty() { 229 + self.flush_batch( 230 + &batch_vertices, 231 + batch_texture, 232 + encoder, 233 + viewport_width, 234 + viewport_height, 235 + ); 236 + batch_vertices.clear(); 237 + } 238 + batch_texture = tex_key; 239 + push_solid_quad( 240 + &mut batch_vertices, 241 + *x, 242 + *y, 243 + *width, 244 + *height, 245 + color_to_f32(color), 246 + ); 247 + } 248 + 249 + PaintCommand::DrawGlyphs { 250 + line, 251 + font_size: _, 252 + color, 253 + } => { 254 + // Build glyph quads from the atlas. 255 + let quads = self.atlas.build_text_quads(line, font); 256 + self.ensure_atlas_textures(); 257 + 258 + for quad in &quads { 259 + let tex_key = TextureKey::AtlasPage(quad.page); 260 + if tex_key != batch_texture && !batch_vertices.is_empty() { 261 + self.flush_batch( 262 + &batch_vertices, 263 + batch_texture, 264 + encoder, 265 + viewport_width, 266 + viewport_height, 267 + ); 268 + batch_vertices.clear(); 269 + } 270 + batch_texture = tex_key; 271 + push_textured_quad(&mut batch_vertices, quad, color_to_f32(color)); 272 + } 273 + } 274 + 275 + PaintCommand::DrawImage { 276 + x, 277 + y, 278 + width, 279 + height, 280 + node_id, 281 + } => { 282 + // Upload image texture if not cached. 283 + let nid = node_id.index(); 284 + if !self.image_cache.contains(nid) { 285 + if let Some(img) = images.get(node_id) { 286 + if let Some(tex) = upload_image(self.renderer.device(), img) { 287 + self.image_cache.insert(nid, tex); 288 + } 289 + } 290 + } 291 + 292 + if self.image_cache.contains(nid) { 293 + let tex_key = TextureKey::Image(nid); 294 + if tex_key != batch_texture && !batch_vertices.is_empty() { 295 + self.flush_batch( 296 + &batch_vertices, 297 + batch_texture, 298 + encoder, 299 + viewport_width, 300 + viewport_height, 301 + ); 302 + batch_vertices.clear(); 303 + } 304 + batch_texture = tex_key; 305 + push_image_quad(&mut batch_vertices, *x, *y, *width, *height); 306 + } 307 + } 308 + 309 + PaintCommand::PushClip { 310 + x, 311 + y, 312 + width, 313 + height, 314 + } => { 315 + // Flush current batch before changing scissor. 316 + if !batch_vertices.is_empty() { 317 + self.flush_batch( 318 + &batch_vertices, 319 + batch_texture, 320 + encoder, 321 + viewport_width, 322 + viewport_height, 323 + ); 324 + batch_vertices.clear(); 325 + } 326 + 327 + let new_clip = ScissorRect { 328 + x: (*x).max(0.0) as u32, 329 + y: (*y).max(0.0) as u32, 330 + width: (*width).max(0.0) as u32, 331 + height: (*height).max(0.0) as u32, 332 + }; 333 + current_scissor = current_scissor.intersect(new_clip); 334 + clip_stack.push(current_scissor); 335 + 336 + // Apply scissor — ensure non-zero dimensions for Metal. 337 + apply_scissor(encoder, current_scissor, vw, vh); 338 + } 339 + 340 + PaintCommand::PopClip => { 341 + // Flush current batch before changing scissor. 342 + if !batch_vertices.is_empty() { 343 + self.flush_batch( 344 + &batch_vertices, 345 + batch_texture, 346 + encoder, 347 + viewport_width, 348 + viewport_height, 349 + ); 350 + batch_vertices.clear(); 351 + } 352 + 353 + clip_stack.pop(); 354 + current_scissor = clip_stack.last().copied().unwrap_or(full_scissor); 355 + apply_scissor(encoder, current_scissor, vw, vh); 356 + } 357 + } 358 + } 359 + 360 + // Flush any remaining vertices. 361 + if !batch_vertices.is_empty() { 362 + self.flush_batch( 363 + &batch_vertices, 364 + batch_texture, 365 + encoder, 366 + viewport_width, 367 + viewport_height, 368 + ); 369 + } 370 + } 371 + 372 + /// Flush a batch of vertices as a single draw call. 373 + fn flush_batch( 374 + &self, 375 + vertices: &[Vertex], 376 + texture_key: TextureKey, 377 + encoder: &RenderCommandEncoder, 378 + _viewport_width: f32, 379 + _viewport_height: f32, 380 + ) { 381 + if vertices.is_empty() { 382 + return; 383 + } 384 + 385 + // Create vertex buffer. 386 + let buffer = match self.renderer.device().new_buffer_with_vertices(vertices) { 387 + Some(b) => b, 388 + None => return, 389 + }; 390 + 391 + // Bind the appropriate texture. 392 + match texture_key { 393 + TextureKey::Solid => { 394 + encoder.set_fragment_texture(self.renderer.dummy_texture(), 0); 395 + } 396 + TextureKey::AtlasPage(page) => { 397 + if let Some(Some(tex)) = self.atlas_textures.get(page) { 398 + encoder.set_fragment_texture(tex, 0); 399 + } else { 400 + encoder.set_fragment_texture(self.renderer.dummy_texture(), 0); 401 + } 402 + } 403 + TextureKey::Image(nid) => { 404 + if let Some(tex) = self.image_cache.get(nid) { 405 + encoder.set_fragment_texture(tex, 0); 406 + encoder.set_fragment_sampler_state(&self.image_sampler, 0); 407 + } else { 408 + encoder.set_fragment_texture(self.renderer.dummy_texture(), 0); 409 + } 410 + } 411 + } 412 + 413 + encoder.set_vertex_buffer(&buffer, 0, 0); 414 + encoder.draw_primitives(MTL_PRIMITIVE_TYPE_TRIANGLE, 0, vertices.len() as u64); 415 + 416 + // Restore linear sampler after image draws. 417 + if matches!(texture_key, TextureKey::Image(_)) { 418 + encoder.set_fragment_sampler_state(self.renderer.sampler(), 0); 419 + } 420 + } 421 + 422 + /// Ensure GPU textures exist for all atlas pages, uploading dirty pages. 423 + fn ensure_atlas_textures(&mut self) { 424 + let page_count = self.atlas.page_count(); 425 + 426 + // Grow the texture vec if needed. 427 + while self.atlas_textures.len() < page_count { 428 + self.atlas_textures.push(None); 429 + } 430 + 431 + for i in 0..page_count { 432 + let needs_upload = self.atlas_textures[i].is_none() || self.atlas.is_page_dirty(i); 433 + if needs_upload { 434 + if let Some((w, h)) = self.atlas.page_size(i) { 435 + if let Some(pixels) = self.atlas.page_pixels(i) { 436 + if let Some(tex) = self.renderer.device().new_texture_r8(w, h, pixels) { 437 + self.atlas_textures[i] = Some(tex); 438 + self.atlas.clear_dirty(i); 439 + } 440 + } 441 + } 442 + } 443 + } 444 + } 445 + 446 + /// Clear the image texture cache. Call when navigating to a new page. 447 + pub fn clear_image_cache(&mut self) { 448 + self.image_cache.clear(); 449 + } 450 + } 451 + 452 + // --------------------------------------------------------------------------- 453 + // Vertex helpers 454 + // --------------------------------------------------------------------------- 455 + 456 + /// Push 6 vertices (2 triangles) for a solid-color rectangle. 457 + fn push_solid_quad(out: &mut Vec<Vertex>, x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) { 458 + let x0 = x; 459 + let y0 = y; 460 + let x1 = x + w; 461 + let y1 = y + h; 462 + 463 + // Triangle 1: TL, TR, BL 464 + out.push(Vertex { 465 + position: [x0, y0], 466 + color, 467 + tex_coord: [0.0, 0.0], 468 + use_texture: 0.0, 469 + }); 470 + out.push(Vertex { 471 + position: [x1, y0], 472 + color, 473 + tex_coord: [0.0, 0.0], 474 + use_texture: 0.0, 475 + }); 476 + out.push(Vertex { 477 + position: [x0, y1], 478 + color, 479 + tex_coord: [0.0, 0.0], 480 + use_texture: 0.0, 481 + }); 482 + 483 + // Triangle 2: TR, BR, BL 484 + out.push(Vertex { 485 + position: [x1, y0], 486 + color, 487 + tex_coord: [0.0, 0.0], 488 + use_texture: 0.0, 489 + }); 490 + out.push(Vertex { 491 + position: [x1, y1], 492 + color, 493 + tex_coord: [0.0, 0.0], 494 + use_texture: 0.0, 495 + }); 496 + out.push(Vertex { 497 + position: [x0, y1], 498 + color, 499 + tex_coord: [0.0, 0.0], 500 + use_texture: 0.0, 501 + }); 502 + } 503 + 504 + /// Push 6 vertices (2 triangles) for a textured glyph quad. 505 + fn push_textured_quad(out: &mut Vec<Vertex>, quad: &TexturedQuad, color: [f32; 4]) { 506 + let x0 = quad.x; 507 + let y0 = quad.y; 508 + let x1 = quad.x + quad.width; 509 + let y1 = quad.y + quad.height; 510 + let u0 = quad.u0; 511 + let v0 = quad.v0; 512 + let u1 = quad.u1; 513 + let v1 = quad.v1; 514 + 515 + // Triangle 1: TL, TR, BL 516 + out.push(Vertex { 517 + position: [x0, y0], 518 + color, 519 + tex_coord: [u0, v0], 520 + use_texture: 1.0, 521 + }); 522 + out.push(Vertex { 523 + position: [x1, y0], 524 + color, 525 + tex_coord: [u1, v0], 526 + use_texture: 1.0, 527 + }); 528 + out.push(Vertex { 529 + position: [x0, y1], 530 + color, 531 + tex_coord: [u0, v1], 532 + use_texture: 1.0, 533 + }); 534 + 535 + // Triangle 2: TR, BR, BL 536 + out.push(Vertex { 537 + position: [x1, y0], 538 + color, 539 + tex_coord: [u1, v0], 540 + use_texture: 1.0, 541 + }); 542 + out.push(Vertex { 543 + position: [x1, y1], 544 + color, 545 + tex_coord: [u1, v1], 546 + use_texture: 1.0, 547 + }); 548 + out.push(Vertex { 549 + position: [x0, y1], 550 + color, 551 + tex_coord: [u0, v1], 552 + use_texture: 1.0, 553 + }); 554 + } 555 + 556 + /// Push 6 vertices (2 triangles) for a textured image quad. 557 + fn push_image_quad(out: &mut Vec<Vertex>, x: f32, y: f32, w: f32, h: f32) { 558 + let x0 = x; 559 + let y0 = y; 560 + let x1 = x + w; 561 + let y1 = y + h; 562 + let color = [1.0, 1.0, 1.0, 1.0]; // no tint 563 + 564 + // Triangle 1: TL, TR, BL 565 + out.push(Vertex { 566 + position: [x0, y0], 567 + color, 568 + tex_coord: [0.0, 0.0], 569 + use_texture: 1.0, 570 + }); 571 + out.push(Vertex { 572 + position: [x1, y0], 573 + color, 574 + tex_coord: [1.0, 0.0], 575 + use_texture: 1.0, 576 + }); 577 + out.push(Vertex { 578 + position: [x0, y1], 579 + color, 580 + tex_coord: [0.0, 1.0], 581 + use_texture: 1.0, 582 + }); 583 + 584 + // Triangle 2: TR, BR, BL 585 + out.push(Vertex { 586 + position: [x1, y0], 587 + color, 588 + tex_coord: [1.0, 0.0], 589 + use_texture: 1.0, 590 + }); 591 + out.push(Vertex { 592 + position: [x1, y1], 593 + color, 594 + tex_coord: [1.0, 1.0], 595 + use_texture: 1.0, 596 + }); 597 + out.push(Vertex { 598 + position: [x0, y1], 599 + color, 600 + tex_coord: [0.0, 1.0], 601 + use_texture: 1.0, 602 + }); 603 + } 604 + 605 + // --------------------------------------------------------------------------- 606 + // Helpers 607 + // --------------------------------------------------------------------------- 608 + 609 + /// Convert a CSS RGBA color to a float array [r, g, b, a] in 0.0–1.0. 610 + fn color_to_f32(c: &Color) -> [f32; 4] { 611 + [ 612 + c.r as f32 / 255.0, 613 + c.g as f32 / 255.0, 614 + c.b as f32 / 255.0, 615 + c.a as f32 / 255.0, 616 + ] 617 + } 618 + 619 + /// Upload an image to the GPU as an RGBA8 texture. 620 + fn upload_image(device: &Device, img: &Image) -> Option<Texture> { 621 + if img.width == 0 || img.height == 0 || img.data.is_empty() { 622 + return None; 623 + } 624 + 625 + // Convert RGBA to BGRA for Metal's preferred pixel format. 626 + let mut bgra = Vec::with_capacity(img.data.len()); 627 + for chunk in img.data.chunks_exact(4) { 628 + bgra.push(chunk[2]); // B 629 + bgra.push(chunk[1]); // G 630 + bgra.push(chunk[0]); // R 631 + bgra.push(chunk[3]); // A 632 + } 633 + 634 + device.new_texture( 635 + img.width, 636 + img.height, 637 + MTL_PIXEL_FORMAT_BGRA8_UNORM, 638 + &bgra, 639 + img.width * 4, 640 + ) 641 + } 642 + 643 + /// Apply a scissor rect to the encoder, clamping to valid dimensions. 644 + fn apply_scissor(encoder: &RenderCommandEncoder, rect: ScissorRect, vw: u32, vh: u32) { 645 + // Metal requires scissor rect to be within the render target. 646 + let x = rect.x.min(vw); 647 + let y = rect.y.min(vh); 648 + let w = rect.width.min(vw.saturating_sub(x)).max(1); 649 + let h = rect.height.min(vh.saturating_sub(y)).max(1); 650 + encoder.set_scissor_rect(x as u64, y as u64, w as u64, h as u64); 651 + } 652 + 653 + // --------------------------------------------------------------------------- 654 + // Tests 655 + // --------------------------------------------------------------------------- 656 + 657 + #[cfg(test)] 658 + mod tests { 659 + use super::*; 660 + 661 + #[test] 662 + fn scissor_rect_full() { 663 + let r = ScissorRect::full(800, 600); 664 + assert_eq!(r.x, 0); 665 + assert_eq!(r.y, 0); 666 + assert_eq!(r.width, 800); 667 + assert_eq!(r.height, 600); 668 + } 669 + 670 + #[test] 671 + fn scissor_rect_intersect() { 672 + let a = ScissorRect { 673 + x: 0, 674 + y: 0, 675 + width: 100, 676 + height: 100, 677 + }; 678 + let b = ScissorRect { 679 + x: 50, 680 + y: 50, 681 + width: 100, 682 + height: 100, 683 + }; 684 + let c = a.intersect(b); 685 + assert_eq!(c.x, 50); 686 + assert_eq!(c.y, 50); 687 + assert_eq!(c.width, 50); 688 + assert_eq!(c.height, 50); 689 + } 690 + 691 + #[test] 692 + fn scissor_rect_no_overlap() { 693 + let a = ScissorRect { 694 + x: 0, 695 + y: 0, 696 + width: 50, 697 + height: 50, 698 + }; 699 + let b = ScissorRect { 700 + x: 100, 701 + y: 100, 702 + width: 50, 703 + height: 50, 704 + }; 705 + let c = a.intersect(b); 706 + assert_eq!(c.width, 0); 707 + assert_eq!(c.height, 0); 708 + } 709 + 710 + #[test] 711 + fn color_conversion() { 712 + let c = Color { 713 + r: 255, 714 + g: 128, 715 + b: 0, 716 + a: 255, 717 + }; 718 + let f = color_to_f32(&c); 719 + assert!((f[0] - 1.0).abs() < 0.01); 720 + assert!((f[1] - 0.502).abs() < 0.01); 721 + assert!((f[2] - 0.0).abs() < 0.01); 722 + assert!((f[3] - 1.0).abs() < 0.01); 723 + } 724 + 725 + #[test] 726 + fn solid_quad_vertex_count() { 727 + let mut verts = Vec::new(); 728 + push_solid_quad(&mut verts, 0.0, 0.0, 100.0, 50.0, [1.0, 0.0, 0.0, 1.0]); 729 + assert_eq!(verts.len(), 6); 730 + // All vertices should have use_texture = 0.0 731 + for v in &verts { 732 + assert_eq!(v.use_texture, 0.0); 733 + } 734 + } 735 + 736 + #[test] 737 + fn textured_quad_vertex_count() { 738 + let quad = TexturedQuad { 739 + x: 10.0, 740 + y: 20.0, 741 + width: 8.0, 742 + height: 12.0, 743 + u0: 0.0, 744 + v0: 0.0, 745 + u1: 0.5, 746 + v1: 0.5, 747 + color: [1.0, 1.0, 1.0, 1.0], 748 + page: 0, 749 + }; 750 + let mut verts = Vec::new(); 751 + push_textured_quad(&mut verts, &quad, [1.0, 1.0, 1.0, 1.0]); 752 + assert_eq!(verts.len(), 6); 753 + // All vertices should have use_texture = 1.0 754 + for v in &verts { 755 + assert_eq!(v.use_texture, 1.0); 756 + } 757 + } 758 + 759 + #[test] 760 + fn image_quad_vertex_count() { 761 + let mut verts = Vec::new(); 762 + push_image_quad(&mut verts, 0.0, 0.0, 200.0, 150.0); 763 + assert_eq!(verts.len(), 6); 764 + for v in &verts { 765 + assert_eq!(v.use_texture, 1.0); 766 + } 767 + // Check UV coordinates cover full image (0,0)→(1,1) 768 + assert_eq!(verts[0].tex_coord, [0.0, 0.0]); // TL 769 + assert_eq!(verts[1].tex_coord, [1.0, 0.0]); // TR 770 + assert_eq!(verts[4].tex_coord, [1.0, 1.0]); // BR 771 + } 772 + 773 + #[test] 774 + fn texture_key_equality() { 775 + assert_eq!(TextureKey::Solid, TextureKey::Solid); 776 + assert_eq!(TextureKey::AtlasPage(0), TextureKey::AtlasPage(0)); 777 + assert_ne!(TextureKey::AtlasPage(0), TextureKey::AtlasPage(1)); 778 + assert_ne!(TextureKey::Solid, TextureKey::AtlasPage(0)); 779 + assert_ne!(TextureKey::Solid, TextureKey::Image(0)); 780 + } 781 + }
+1
crates/render/src/lib.rs
··· 4 4 //! into a BGRA pixel buffer suitable for display via CoreGraphics. 5 5 6 6 pub mod atlas; 7 + pub mod gpu; 7 8 8 9 use std::collections::HashMap; 9 10