//! Metal GPU renderer: translates display list paint commands into batched //! Metal draw calls for hardware-accelerated rendering. //! //! This is the production rendering path. Walks the `DisplayList` in painter's //! order, converts each `PaintCommand` into GPU vertex data, and submits //! batched draw calls via the Metal render pipeline. //! //! Supports compositing layers: elements with `opacity < 1.0` (or promoted via //! `will-change`) are rendered to intermediate textures and composited back //! with the correct group opacity. use std::collections::HashMap; use we_css::values::{ Color, GradientStop, LinearGradient, LinearGradientDirection, RadialGradient, }; use we_dom::NodeId; use we_image::pixel::Image; use we_platform::metal::{ self, ClearColor, ClipParams, CommandBuffer, CommandQueue, Device, MetalLayer, MetalRenderer, RenderCommandEncoder, SamplerState, Texture, TextureCache, Uniforms, Vertex, MTL_PIXEL_FORMAT_BGRA8_UNORM, MTL_PRIMITIVE_TYPE_TRIANGLE, TEXTURE_MODE_COLOR, TEXTURE_MODE_MASK, TEXTURE_MODE_SDF, TEXTURE_MODE_SHADOW, TEXTURE_MODE_SOLID, }; use we_style::computed::{BackgroundRepeat, LengthOrAuto, ObjectFit}; use we_text::font::{Font, FontRegistry}; use crate::atlas::{GlyphAtlas, TexturedQuad}; use crate::{DisplayList, PaintCommand}; // --------------------------------------------------------------------------- // Clip rect (integer, for Metal scissor rects) // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy)] struct ScissorRect { x: u32, y: u32, width: u32, height: u32, } impl ScissorRect { fn full(viewport_width: u32, viewport_height: u32) -> Self { ScissorRect { x: 0, y: 0, width: viewport_width, height: viewport_height, } } fn intersect(self, other: ScissorRect) -> ScissorRect { let x0 = self.x.max(other.x); let y0 = self.y.max(other.y); let x1 = (self.x + self.width).min(other.x + other.width); let y1 = (self.y + self.height).min(other.y + other.height); if x1 <= x0 || y1 <= y0 { ScissorRect { x: 0, y: 0, width: 0, height: 0, } } else { ScissorRect { x: x0, y: y0, width: x1 - x0, height: y1 - y0, } } } } // --------------------------------------------------------------------------- // Texture key // --------------------------------------------------------------------------- /// Identifies which texture a batch uses. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum TextureKey { /// Solid-color quads — bind the 1x1 dummy white texture. Solid, /// Glyph atlas page. AtlasPage(usize), /// Image node texture. Image(usize), } /// Font inputs used by the GPU text renderer. #[derive(Clone, Copy)] pub struct RenderFonts<'a> { pub base: &'a Font, pub registry: Option<&'a FontRegistry>, } impl<'a> RenderFonts<'a> { pub fn new(base: &'a Font, registry: Option<&'a FontRegistry>) -> Self { RenderFonts { base, registry } } } #[derive(Clone, Hash, PartialEq, Eq)] struct FontKey { family_list: String, weight: u16, italic: bool, } struct FontResolver<'a> { registry: Option<&'a FontRegistry>, fallback: &'a Font, cache: HashMap, } impl<'a> FontResolver<'a> { fn new(registry: Option<&'a FontRegistry>, fallback: &'a Font) -> Self { FontResolver { registry, fallback, cache: HashMap::new(), } } fn resolve(&mut self, family_list: &str, font_weight: f32, italic: bool) -> &Font { let Some(registry) = self.registry else { return self.fallback; }; if family_list.trim().is_empty() { return self.fallback; } let weight = normalize_font_weight(font_weight); let key = FontKey { family_list: family_list.to_string(), weight, italic, }; if !self.cache.contains_key(&key) { if let Some(font) = registry.find_font_for_family_list_with_weight(family_list, weight, italic) { self.cache.insert(key.clone(), font); } } self.cache.get(&key).unwrap_or(self.fallback) } } fn normalize_font_weight(weight: f32) -> u16 { if !weight.is_finite() { return 400; } weight.round().clamp(1.0, 1000.0) as u16 } // --------------------------------------------------------------------------- // Compositing layer state // --------------------------------------------------------------------------- /// Saved state for a compositing layer that is being rendered to an /// intermediate texture. struct LayerState { /// `true` if this layer does not need an intermediate texture (opacity=1.0, /// no isolation needed). Content is rendered directly to the parent target. passthrough: bool, /// Offscreen render target texture (only for non-passthrough layers). This /// is the single-sample resolve target that the layer's MSAA samples are /// resolved into, and that is later sampled when compositing back. texture: Option, /// Multisample companion for `texture`. Held for the layer's lifetime so the /// MSAA samples survive any Load passes (e.g. when a nested sub-layer pops /// and the parent layer is reopened). msaa_texture: Option, /// Layer bounds in parent screen coordinates. bounds_x: f32, bounds_y: f32, bounds_w: f32, bounds_h: f32, /// Layer opacity (applied when compositing back to parent). opacity: f32, /// Saved parent render target texture Id. parent_target_id: we_platform::objc::Id, /// Saved parent MSAA companion texture Id. parent_msaa_id: we_platform::objc::Id, /// Saved parent viewport dimensions (pixels). parent_vw: u32, parent_vh: u32, /// Saved parent coordinate offset. parent_coord_offset: (f32, f32), /// Saved parent clip stack. saved_clip_stack: Vec, /// Saved parent scissor rect. saved_scissor: ScissorRect, /// Saved parent rounded-clip shape stack. saved_clip_shape_stack: Vec, /// Saved parent active rounded-clip shape. saved_clip_shape: ClipParams, } // --------------------------------------------------------------------------- // GpuRenderer // --------------------------------------------------------------------------- /// Metal GPU renderer that processes a `DisplayList` into batched draw calls. /// /// Owns a glyph atlas and image texture cache to avoid redundant GPU uploads. /// Supports compositing layers for correct group opacity rendering. pub struct GpuRenderer { renderer: MetalRenderer, atlas: GlyphAtlas, image_cache: TextureCache, /// GPU textures for each atlas page. atlas_textures: Vec>, /// Linear sampler for raster images. CSS images are sampled from their /// decoded level-0 pixels; generated Metal mipmaps noticeably diverge from /// browser image resampling on real-web photographic backgrounds. image_sampler: SamplerState, /// Nearest sampler for R8 glyph masks. Glyph bitmaps are already /// supersampled at their target device-pixel size; linear filtering would /// blend mask edges against the atlas padding and thin text. mask_sampler: SamplerState, /// Device pixels per logical point (Retina backing scale). The display /// list stays in logical coordinates; this scales the drawable, scissor /// rects, and glyph rasterization so content is rendered at device /// resolution instead of being upscaled by the compositor. scale: f32, /// Cached MSAA companion texture for the on-screen drawable, sized in device /// pixels. The pipeline rasterizes into this 4×-multisampled target and /// resolves into the drawable on store. Recreated when the drawable size /// changes. (`render_to_pixels` builds its own per-call MSAA texture.) screen_msaa: Option, } impl GpuRenderer { /// Create a new GPU renderer on the given Metal device. /// /// Returns `None` if Metal pipeline creation fails. pub fn new(device: Device) -> Option { let image_sampler = device.new_sampler_state()?; let mask_sampler = device.new_sampler_state_nearest()?; let renderer = MetalRenderer::new(device)?; Some(GpuRenderer { renderer, atlas: GlyphAtlas::new(), image_cache: TextureCache::new(), atlas_textures: Vec::new(), image_sampler, mask_sampler, scale: 1.0, screen_msaa: None, }) } /// Set the device backing scale (e.g. 2.0 on Retina). The caller must size /// the Metal drawable to `logical_size × scale` to match. pub fn set_scale(&mut self, scale: f32) { self.scale = scale.max(1.0); } /// Current device backing scale. pub fn scale(&self) -> f32 { self.scale } /// Render a display list to the Metal layer. /// /// Gets the next drawable from the layer, encodes all paint commands as /// batched GPU draw calls (including compositing layers for elements with /// opacity < 1.0), and presents the result. #[allow(clippy::too_many_arguments)] pub fn render( &mut self, display_list: &DisplayList, fonts: RenderFonts<'_>, images: &HashMap, layer: &MetalLayer, queue: &CommandQueue, clear_color: ClearColor, viewport_width: f32, viewport_height: f32, ) { let vw = viewport_width as u32; let vh = viewport_height as u32; if vw == 0 || vh == 0 { return; } // Get next drawable from the layer. let drawable = match layer.next_drawable() { Some(d) => d, None => return, }; let texture_id = match metal::drawable_texture(drawable) { Some(t) => t, None => return, }; // Create command buffer — all render passes will be encoded here. let cmd_buf = match queue.command_buffer() { Some(b) => b, None => return, }; // Ensure an MSAA companion sized to the device-pixel drawable. The // display list is in logical points; the drawable is `logical × scale`. let dw = (viewport_width * self.scale).round().max(1.0) as u32; let dh = (viewport_height * self.scale).round().max(1.0) as u32; self.ensure_screen_msaa(dw, dh); let msaa_id = match self.screen_msaa.as_ref() { Some(t) => t.id(), None => return, }; // Process the display list, creating/ending encoders as needed for layers. self.encode_display_list_with_layers( display_list, fonts, images, &cmd_buf, texture_id, msaa_id, clear_color, vw, vh, ); cmd_buf.present_drawable(drawable); cmd_buf.commit(); } /// Render a display list offscreen and read the result back to CPU memory /// as tightly-packed BGRA8 (`width * height * 4` bytes). /// /// This is the headless rendering path — same Metal pipeline as the /// on-screen `render`, but targeting a CPU-readable texture instead of a /// drawable. Used by the e2e harness and iframe snapshots so tests exercise /// the exact production renderer. Returns `None` if Metal resource creation /// fails. `width`/`height` are in pixels; callers wanting Retina-scale /// output should size accordingly and `set_scale` to match. pub fn render_to_pixels( &mut self, display_list: &DisplayList, fonts: RenderFonts<'_>, images: &HashMap, width: u32, height: u32, clear_color: ClearColor, ) -> Option> { if width == 0 || height == 0 { return None; } let device = self.renderer.device(); let target = device.new_readback_target_texture(width, height)?; // MSAA rasterization target resolved into `target` on store. Kept alive // until after the GPU work completes below. let msaa = device.new_msaa_texture(width, height, self.renderer.sample_count())?; let queue = device.new_command_queue()?; let cmd_buf = queue.command_buffer()?; self.encode_display_list_with_layers( display_list, fonts, images, &cmd_buf, target.id(), msaa.id(), clear_color, width, height, ); cmd_buf.commit(); cmd_buf.wait_until_completed(); Some(target.get_bytes_bgra()) } /// Process the display list into Metal draw calls, handling compositing /// layers by rendering to intermediate textures when needed. #[allow(clippy::too_many_arguments)] fn encode_display_list_with_layers( &mut self, display_list: &DisplayList, fonts: RenderFonts<'_>, images: &HashMap, cmd_buf: &CommandBuffer, initial_target_id: we_platform::objc::Id, initial_msaa_id: we_platform::objc::Id, clear_color: ClearColor, initial_vw: u32, initial_vh: u32, ) { // Create the initial render pass: rasterize into the MSAA target and // resolve into the drawable/readback texture on store. let desc = match metal::make_clear_pass_descriptor_msaa( initial_msaa_id, initial_target_id, clear_color, ) { Some(d) => d, None => return, }; let mut encoder = match cmd_buf.render_command_encoder(desc) { Some(e) => e, None => return, }; self.setup_encoder(&encoder, initial_vw as f32, initial_vh as f32); // Current render target state. let mut current_target_id = initial_target_id; // MSAA companion for the current resolve target, kept in lockstep with // `current_target_id`. Every pass binds this as the color attachment and // resolves into `current_target_id`. let mut current_msaa_id = initial_msaa_id; let mut current_vw = initial_vw; let mut current_vh = initial_vh; // Device backing scale. Uniforms and `current_vw/vh` stay in logical // points (so the vertex shader maps the logical display list across the // device-sized drawable, rasterizing geometry at full device // resolution). Scissor rects, however, are in device pixels, so all // clip coordinates are multiplied by `scale` via `dev`. let scale = self.scale; let dev = |v: u32| (v as f32 * scale).round() as u32; // Coordinate offset: subtracted from display list positions when rendering // to a layer texture (maps screen coords → layer-local coords). let mut coord_offset: (f32, f32) = (0.0, 0.0); // Clip stack and scissor state for the current render target (device px). let full_scissor = ScissorRect::full(dev(current_vw), dev(current_vh)); let mut clip_stack: Vec = vec![full_scissor]; let mut current_scissor = full_scissor; // Rounded-clip shape stack, parallel to `clip_stack`. Each entry is the // active rounded clip in *logical* coordinates (matching the shader's // `world_pos`). A rectangular clip inherits its parent's rounded shape so // a `border-radius` container's corners keep clipping descendants even // through nested rectangular clips. `current_clip` is bound to the // fragment shader; `setup_encoder` binds a disabled clip by default. let mut clip_shape_stack: Vec = vec![ClipParams::disabled()]; let mut current_clip = ClipParams::disabled(); // Layer stack for nested compositing layers. let mut layer_stack: Vec = Vec::new(); // Current batch of vertices and their texture key. let mut batch_vertices: Vec = Vec::with_capacity(4096); let mut batch_texture: TextureKey = TextureKey::Solid; let mut font_resolver = FontResolver::new(fonts.registry, fonts.base); for cmd in display_list { match cmd { PaintCommand::PushLayer { opacity, x, y, width, height, } => { let needs_texture = *opacity < 1.0; if needs_texture { // Flush and end current encoder before switching targets. if !batch_vertices.is_empty() { self.flush_batch(&batch_vertices, batch_texture, &encoder); batch_vertices.clear(); } encoder.end_encoding(); // Compute layer texture dimensions (in pixels). let adjusted_x = *x + coord_offset.0; let adjusted_y = *y + coord_offset.1; let layer_w = (*width).ceil().max(1.0) as u32; let layer_h = (*height).ceil().max(1.0) as u32; // Save parent state. layer_stack.push(LayerState { passthrough: false, texture: None, // filled below msaa_texture: None, // filled below bounds_x: adjusted_x, bounds_y: adjusted_y, bounds_w: *width, bounds_h: *height, opacity: *opacity, parent_target_id: current_target_id, parent_msaa_id: current_msaa_id, parent_vw: current_vw, parent_vh: current_vh, parent_coord_offset: coord_offset, saved_clip_stack: clip_stack.clone(), saved_scissor: current_scissor, saved_clip_shape_stack: clip_shape_stack.clone(), saved_clip_shape: current_clip, }); // Create the offscreen resolve target plus its MSAA // companion, both at device resolution. let device = self.renderer.device(); let sample_count = self.renderer.sample_count(); let offscreen = device.new_render_target_texture(dev(layer_w), dev(layer_h)); let offscreen_msaa = device.new_msaa_texture(dev(layer_w), dev(layer_h), sample_count); if let (Some(offscreen), Some(offscreen_msaa)) = (offscreen, offscreen_msaa) { let transparent = ClearColor::new(0.0, 0.0, 0.0, 0.0); let off_desc = metal::make_clear_pass_descriptor_msaa( offscreen_msaa.id(), offscreen.id(), transparent, ); if let Some(desc) = off_desc { if let Some(enc) = cmd_buf.render_command_encoder(desc) { // Switch to the layer's render target. encoder = enc; self.setup_encoder(&encoder, layer_w as f32, layer_h as f32); // Update state for the layer. current_target_id = offscreen.id(); current_msaa_id = offscreen_msaa.id(); current_vw = layer_w; current_vh = layer_h; // Offset coordinates so screen positions map // to layer-local positions. coord_offset.0 -= *x; coord_offset.1 -= *y; // Reset clip stack for the new target (device px). let layer_full = ScissorRect::full(dev(layer_w), dev(layer_h)); clip_stack = vec![layer_full]; current_scissor = layer_full; // Reset rounded-clip shape for the new target; // `setup_encoder` already bound a disabled clip. clip_shape_stack = vec![ClipParams::disabled()]; current_clip = ClipParams::disabled(); // Store both textures in the layer state so // they outlive the layer's passes. if let Some(state) = layer_stack.last_mut() { state.texture = Some(offscreen); state.msaa_texture = Some(offscreen_msaa); } continue; } } } // If render target creation failed, fall back: reopen // the parent target with Load action and treat as passthrough. if let Some(state) = layer_stack.last_mut() { state.passthrough = true; } let load_desc = metal::make_load_pass_descriptor_msaa( current_msaa_id, current_target_id, ); if let Some(desc) = load_desc { if let Some(enc) = cmd_buf.render_command_encoder(desc) { encoder = enc; self.setup_encoder(&encoder, current_vw as f32, current_vh as f32); apply_scissor( &encoder, current_scissor, dev(current_vw), dev(current_vh), ); encoder.set_fragment_bytes(¤t_clip, 0); } } } else { // Opacity 1.0 — no intermediate texture needed. layer_stack.push(LayerState { passthrough: true, texture: None, msaa_texture: None, bounds_x: 0.0, bounds_y: 0.0, bounds_w: 0.0, bounds_h: 0.0, opacity: 1.0, parent_target_id: current_target_id, parent_msaa_id: current_msaa_id, parent_vw: current_vw, parent_vh: current_vh, parent_coord_offset: coord_offset, saved_clip_stack: Vec::new(), saved_scissor: current_scissor, saved_clip_shape_stack: Vec::new(), saved_clip_shape: current_clip, }); } } PaintCommand::PopLayer => { if let Some(state) = layer_stack.pop() { if !state.passthrough { // Flush and end the layer encoder. if !batch_vertices.is_empty() { self.flush_batch(&batch_vertices, batch_texture, &encoder); batch_vertices.clear(); } encoder.end_encoding(); // Restore parent state. current_target_id = state.parent_target_id; current_msaa_id = state.parent_msaa_id; current_vw = state.parent_vw; current_vh = state.parent_vh; coord_offset = state.parent_coord_offset; clip_stack = state.saved_clip_stack; current_scissor = state.saved_scissor; clip_shape_stack = state.saved_clip_shape_stack; current_clip = state.saved_clip_shape; // Resume rendering to the parent target (Load action // preserves the parent's retained MSAA samples). let load_desc = match metal::make_load_pass_descriptor_msaa( current_msaa_id, current_target_id, ) { Some(d) => d, None => continue, }; encoder = match cmd_buf.render_command_encoder(load_desc) { Some(e) => e, None => continue, }; self.setup_encoder(&encoder, current_vw as f32, current_vh as f32); apply_scissor( &encoder, current_scissor, dev(current_vw), dev(current_vh), ); // Rebind the restored rounded clip so the composited // layer quad is clipped to any parent rounded corners. encoder.set_fragment_bytes(¤t_clip, 0); // Composite the layer texture as a quad with opacity. if let Some(ref tex) = state.texture { let color = [1.0, 1.0, 1.0, state.opacity]; push_layer_quad( &mut batch_vertices, state.bounds_x, state.bounds_y, state.bounds_w, state.bounds_h, color, ); // Flush immediately with the layer texture bound. self.flush_batch_with_texture(&batch_vertices, tex, &encoder); batch_vertices.clear(); } } } } PaintCommand::FillRect { x, y, width, height, color, } => { let tex_key = TextureKey::Solid; if tex_key != batch_texture && !batch_vertices.is_empty() { self.flush_batch(&batch_vertices, batch_texture, &encoder); batch_vertices.clear(); } batch_texture = tex_key; push_solid_quad( &mut batch_vertices, *x + coord_offset.0, *y + coord_offset.1, *width, *height, color_to_f32(color), ); } PaintCommand::LinearGradient { x, y, width, height, gradient, } => { let tex_key = TextureKey::Solid; if tex_key != batch_texture && !batch_vertices.is_empty() { self.flush_batch(&batch_vertices, batch_texture, &encoder); batch_vertices.clear(); } batch_texture = tex_key; push_linear_gradient_quad( &mut batch_vertices, *x + coord_offset.0, *y + coord_offset.1, *width, *height, gradient, ); } PaintCommand::RadialGradient { x, y, width, height, position_x, position_y, repeat, gradient, } => { let tex_key = TextureKey::Solid; if tex_key != batch_texture && !batch_vertices.is_empty() { self.flush_batch(&batch_vertices, batch_texture, &encoder); batch_vertices.clear(); } batch_texture = tex_key; push_radial_gradient_quad( &mut batch_vertices, (*x + coord_offset.0, *y + coord_offset.1, *width, *height), (*position_x, *position_y), *repeat, gradient, ); } PaintCommand::RoundedRect { x, y, width, height, radii, stroke_width, color, } => { // SDF rounded rects share the solid (no-texture) batch; the // fragment shader branches on the per-vertex SDF mode. let tex_key = TextureKey::Solid; if tex_key != batch_texture && !batch_vertices.is_empty() { self.flush_batch(&batch_vertices, batch_texture, &encoder); batch_vertices.clear(); } batch_texture = tex_key; push_sdf_rounded_rect( &mut batch_vertices, *x + coord_offset.0, *y + coord_offset.1, *width, *height, *radii, *stroke_width, color_to_f32(color), ); } PaintCommand::BoxShadow { x, y, width, height, radii, blur_radius, color, } => { let tex_key = TextureKey::Solid; if tex_key != batch_texture && !batch_vertices.is_empty() { self.flush_batch(&batch_vertices, batch_texture, &encoder); batch_vertices.clear(); } batch_texture = tex_key; push_sdf_box_shadow( &mut batch_vertices, *x + coord_offset.0, *y + coord_offset.1, *width, *height, *radii, *blur_radius, color_to_f32(color), ); } PaintCommand::DrawGlyphs { line, font_size: _, color, } => { // Build glyph quads from the atlas. let text_font = font_resolver.resolve(&line.font_family, line.font_weight, line.italic); let quads = self.atlas.build_text_quads(line, text_font, scale); self.ensure_atlas_textures(); for quad in &quads { let tex_key = TextureKey::AtlasPage(quad.page); if tex_key != batch_texture && !batch_vertices.is_empty() { self.flush_batch(&batch_vertices, batch_texture, &encoder); batch_vertices.clear(); } batch_texture = tex_key; push_textured_quad_offset( &mut batch_vertices, quad, color_to_f32(color), coord_offset, ); } } PaintCommand::DrawImage { x, y, width, height, object_fit, node_id, } => { // Upload image texture if not cached. let nid = node_id.index(); if !self.image_cache.contains(nid) { if let Some(img) = images.get(node_id) { if let Some(tex) = upload_image(self.renderer.device(), img) { self.image_cache.insert(nid, tex); } } } if self.image_cache.contains(nid) { let Some(img) = images.get(node_id) else { continue; }; let fitted = resolve_object_fit( *object_fit, *x + coord_offset.0, *y + coord_offset.1, *width, *height, img.width as f32, img.height as f32, ); let tex_key = TextureKey::Image(nid); if tex_key != batch_texture && !batch_vertices.is_empty() { self.flush_batch(&batch_vertices, batch_texture, &encoder); batch_vertices.clear(); } batch_texture = tex_key; push_image_quad_uv( &mut batch_vertices, fitted.x, fitted.y, fitted.width, fitted.height, fitted.u0, fitted.v0, fitted.u1, fitted.v1, ); } } PaintCommand::DrawBackgroundImage { x, y, width, height, node_id, size, position_x, position_y, .. } => { let nid = node_id.index(); if !self.image_cache.contains(nid) { if let Some(img) = images.get(node_id) { if let Some(tex) = upload_image(self.renderer.device(), img) { self.image_cache.insert(nid, tex); } } } if self.image_cache.contains(nid) { let Some(img) = images.get(node_id) else { continue; }; let (draw_w, draw_h) = crate::resolve_background_image_size( *size, *width, *height, img.width as f32, img.height as f32, ); let draw_x = *x + crate::background_position_offset( *position_x, *width - draw_w, *width, ); let draw_y = *y + crate::background_position_offset( *position_y, *height - draw_h, *height, ); let tex_key = TextureKey::Image(nid); if tex_key != batch_texture && !batch_vertices.is_empty() { self.flush_batch(&batch_vertices, batch_texture, &encoder); batch_vertices.clear(); } batch_texture = tex_key; push_image_quad( &mut batch_vertices, draw_x + coord_offset.0, draw_y + coord_offset.1, draw_w, draw_h, ); } } PaintCommand::PushClip { x, y, width, height, radii, } => { // Flush current batch before changing scissor. if !batch_vertices.is_empty() { self.flush_batch(&batch_vertices, batch_texture, &encoder); batch_vertices.clear(); } // Clip rects are in device pixels (scaled from logical). let cx = ((*x + coord_offset.0) * scale).max(0.0) as u32; let cy = ((*y + coord_offset.1) * scale).max(0.0) as u32; let new_clip = ScissorRect { x: cx, y: cy, width: (*width * scale).max(0.0) as u32, height: (*height * scale).max(0.0) as u32, }; current_scissor = current_scissor.intersect(new_clip); clip_stack.push(current_scissor); apply_scissor(&encoder, current_scissor, dev(current_vw), dev(current_vh)); // Rounded clip: a box with non-zero corner radii rounds its // corners off via the fragment-shader SDF; in logical // coordinates (matching `world_pos`). A rectangular clip // inherits the parent's active rounded shape. With a single // clip uniform, the innermost rounded clip wins — adequate for // the common single rounded container case. current_clip = if radii.iter().any(|&r| r > 0.0) { let lx = *x + coord_offset.0; let ly = *y + coord_offset.1; ClipParams::rounded( [lx + *width / 2.0, ly + *height / 2.0], [*width / 2.0, *height / 2.0], *radii, ) } else { *clip_shape_stack.last().unwrap_or(&ClipParams::disabled()) }; clip_shape_stack.push(current_clip); encoder.set_fragment_bytes(¤t_clip, 0); } PaintCommand::PopClip => { // Flush current batch before changing scissor. if !batch_vertices.is_empty() { self.flush_batch(&batch_vertices, batch_texture, &encoder); batch_vertices.clear(); } clip_stack.pop(); current_scissor = clip_stack .last() .copied() .unwrap_or(ScissorRect::full(dev(current_vw), dev(current_vh))); apply_scissor(&encoder, current_scissor, dev(current_vw), dev(current_vh)); clip_shape_stack.pop(); current_clip = clip_shape_stack .last() .copied() .unwrap_or_else(ClipParams::disabled); encoder.set_fragment_bytes(¤t_clip, 0); } } } // Flush any remaining vertices. if !batch_vertices.is_empty() { self.flush_batch(&batch_vertices, batch_texture, &encoder); } encoder.end_encoding(); } /// Set up the render encoder with pipeline state and uniforms. fn setup_encoder( &self, encoder: &RenderCommandEncoder, viewport_width: f32, viewport_height: f32, ) { let uniforms = Uniforms { viewport_size: [viewport_width, viewport_height], }; encoder.set_render_pipeline_state(self.renderer.pipeline()); encoder.set_vertex_bytes(&uniforms, 1); encoder.set_fragment_sampler_state(self.renderer.sampler(), 0); // Always bind a clip uniform so the fragment shader's `buffer(0)` is // defined; a disabled clip is a pass-through. PushClip/PopClip rebind it. encoder.set_fragment_bytes(&ClipParams::disabled(), 0); } /// Flush a batch of vertices as a single draw call. fn flush_batch( &self, vertices: &[Vertex], texture_key: TextureKey, encoder: &RenderCommandEncoder, ) { if vertices.is_empty() { return; } // Create vertex buffer. let buffer = match self.renderer.device().new_buffer_with_vertices(vertices) { Some(b) => b, None => return, }; // Bind the appropriate texture. match texture_key { TextureKey::Solid => { encoder.set_fragment_texture(self.renderer.dummy_texture(), 0); } TextureKey::AtlasPage(page) => { if let Some(Some(tex)) = self.atlas_textures.get(page) { encoder.set_fragment_texture(tex, 0); encoder.set_fragment_sampler_state(&self.mask_sampler, 0); } else { encoder.set_fragment_texture(self.renderer.dummy_texture(), 0); } } TextureKey::Image(nid) => { if let Some(tex) = self.image_cache.get(nid) { encoder.set_fragment_texture(tex, 0); encoder.set_fragment_sampler_state(&self.image_sampler, 0); } else { encoder.set_fragment_texture(self.renderer.dummy_texture(), 0); } } } encoder.set_vertex_buffer(&buffer, 0, 0); encoder.draw_primitives(MTL_PRIMITIVE_TYPE_TRIANGLE, 0, vertices.len() as u64); // Restore linear sampler after image draws. if matches!(texture_key, TextureKey::Image(_)) { encoder.set_fragment_sampler_state(self.renderer.sampler(), 0); } } /// Flush vertices as a draw call using a specific texture (for compositing /// layer textures back onto the parent target). fn flush_batch_with_texture( &self, vertices: &[Vertex], texture: &Texture, encoder: &RenderCommandEncoder, ) { if vertices.is_empty() { return; } let buffer = match self.renderer.device().new_buffer_with_vertices(vertices) { Some(b) => b, None => return, }; encoder.set_fragment_texture(texture, 0); encoder.set_fragment_sampler_state(self.renderer.sampler(), 0); encoder.set_vertex_buffer(&buffer, 0, 0); encoder.draw_primitives(MTL_PRIMITIVE_TYPE_TRIANGLE, 0, vertices.len() as u64); } /// Ensure GPU textures exist for all atlas pages, uploading dirty pages. fn ensure_atlas_textures(&mut self) { let page_count = self.atlas.page_count(); // Grow the texture vec if needed. while self.atlas_textures.len() < page_count { self.atlas_textures.push(None); } for i in 0..page_count { let needs_upload = self.atlas_textures[i].is_none() || self.atlas.is_page_dirty(i); if needs_upload { if let Some((w, h)) = self.atlas.page_size(i) { if let Some(pixels) = self.atlas.page_pixels(i) { if let Some(tex) = self.renderer.device().new_texture_r8(w, h, pixels) { self.atlas_textures[i] = Some(tex); self.atlas.clear_dirty(i); } } } } } } /// Clear the image texture cache. Call when navigating to a new page. pub fn clear_image_cache(&mut self) { self.image_cache.clear(); } /// Ensure the cached on-screen MSAA companion texture matches the given /// device-pixel drawable size, recreating it on size change. The texture is /// the 4×-multisampled rasterization target resolved into the drawable. fn ensure_screen_msaa(&mut self, width: u32, height: u32) { let matches = self .screen_msaa .as_ref() .is_some_and(|t| t.width() == width && t.height() == height); if !matches { self.screen_msaa = self.renderer.device().new_msaa_texture( width, height, self.renderer.sample_count(), ); } } } // --------------------------------------------------------------------------- // Vertex helpers // --------------------------------------------------------------------------- /// Push 6 vertices (2 triangles) for a solid-color rectangle. fn push_solid_quad(out: &mut Vec, x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) { let x0 = x; let y0 = y; let x1 = x + w; let y1 = y + h; // Triangle 1: TL, TR, BL out.push(Vertex { position: [x0, y0], color, tex_coord: [0.0, 0.0], use_texture: TEXTURE_MODE_SOLID, ..Vertex::default() }); out.push(Vertex { position: [x1, y0], color, tex_coord: [0.0, 0.0], use_texture: TEXTURE_MODE_SOLID, ..Vertex::default() }); out.push(Vertex { position: [x0, y1], color, tex_coord: [0.0, 0.0], use_texture: TEXTURE_MODE_SOLID, ..Vertex::default() }); // Triangle 2: TR, BR, BL out.push(Vertex { position: [x1, y0], color, tex_coord: [0.0, 0.0], use_texture: TEXTURE_MODE_SOLID, ..Vertex::default() }); out.push(Vertex { position: [x1, y1], color, tex_coord: [0.0, 0.0], use_texture: TEXTURE_MODE_SOLID, ..Vertex::default() }); out.push(Vertex { position: [x0, y1], color, tex_coord: [0.0, 0.0], use_texture: TEXTURE_MODE_SOLID, ..Vertex::default() }); } #[derive(Clone, Copy)] struct GradientPoint { x: f32, y: f32, } #[derive(Clone, Copy)] struct ResolvedGradientStop { color: Color, position: f32, } const GRADIENT_EPSILON: f32 = 0.0001; /// Push vertices for a CSS linear-gradient rectangle. /// /// A single quad with only corner colors is not enough for positioned or /// intermediate color stops: the GPU would interpolate across the whole box and /// smear stops such as `transparent 60%, black`. Instead, clip the rectangle /// into one polygon per stop interval and triangulate each interval. Because /// `t` is affine for the supported keyword directions, interpolating vertex /// colors within one stop interval is exact. fn push_linear_gradient_quad( out: &mut Vec, x: f32, y: f32, w: f32, h: f32, gradient: &LinearGradient, ) { if w <= 0.0 || h <= 0.0 { return; } let stops = resolve_linear_gradient_stops(gradient); if stops.is_empty() { return; } let mut cuts = vec![0.0, 1.0]; for stop in &stops { if stop.position > 0.0 && stop.position < 1.0 { cuts.push(stop.position); } } cuts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); cuts.dedup_by(|a, b| (*a - *b).abs() <= GRADIENT_EPSILON); let vert = |px: f32, py: f32, color: [f32; 4]| Vertex { position: [px, py], color, tex_coord: [0.0, 0.0], use_texture: TEXTURE_MODE_SOLID, ..Vertex::default() }; for pair in cuts.windows(2) { let t0 = pair[0]; let t1 = pair[1]; if t1 - t0 <= GRADIENT_EPSILON { continue; } let polygon = clip_gradient_interval(gradient.direction, w, h, t0, t1); if polygon.len() < 3 { continue; } let p0 = polygon[0]; for i in 1..polygon.len() - 1 { let p1 = polygon[i]; let p2 = polygon[i + 1]; for p in [p0, p1, p2] { let t = gradient_t(gradient.direction, p.x, p.y, w, h); let color = color_to_f32(&gradient_color_at_interval(&stops, t, t0, t1)); out.push(vert(x + p.x * w, y + p.y * h, color)); } } } } fn push_radial_gradient_quad( out: &mut Vec, rect: (f32, f32, f32, f32), position: (LengthOrAuto, LengthOrAuto), repeat: BackgroundRepeat, gradient: &RadialGradient, ) { let (x, y, w, h) = rect; let (position_x, position_y) = position; if w <= 0.0 || h <= 0.0 { return; } let stops = resolve_radial_gradient_stops(gradient); if stops.is_empty() { return; } let offset_x = crate::background_position_offset(position_x, 0.0, w); let offset_y = crate::background_position_offset(position_y, 0.0, h); let image_x = x + offset_x; let image_y = y + offset_y; let image_right = image_x + w; let image_bottom = image_y + h; let (paint_x, paint_y, paint_w, paint_h) = match repeat { BackgroundRepeat::Repeat => (x, y, w, h), BackgroundRepeat::NoRepeat => { let left = x.max(image_x); let top = y.max(image_y); let right = (x + w).min(image_right); let bottom = (y + h).min(image_bottom); if right <= left || bottom <= top { return; } (left, top, right - left, bottom - top) } }; let center_x = image_x + w * 0.5; let center_y = image_y + h * 0.5; let radius = [ (image_x, image_y), (image_right, image_y), (image_right, image_bottom), (image_x, image_bottom), ] .iter() .map(|(px, py)| (*px - center_x).hypot(*py - center_y)) .fold(0.0, f32::max); if radius <= f32::EPSILON { return; } let cols = ((paint_w / 18.0).ceil() as usize).clamp(8, 96); let rows = ((paint_h / 18.0).ceil() as usize).clamp(8, 96); let vertex = |px: f32, py: f32| { let t = ((px - center_x).hypot(py - center_y) / radius).clamp(0.0, 1.0); Vertex { position: [px, py], color: color_to_f32(&gradient_color_at_resolved(&stops, t)), tex_coord: [0.0, 0.0], use_texture: TEXTURE_MODE_SOLID, ..Vertex::default() } }; for row in 0..rows { let y0 = paint_y + paint_h * row as f32 / rows as f32; let y1 = paint_y + paint_h * (row + 1) as f32 / rows as f32; for col in 0..cols { let x0 = paint_x + paint_w * col as f32 / cols as f32; let x1 = paint_x + paint_w * (col + 1) as f32 / cols as f32; let top_left = vertex(x0, y0); let top_right = vertex(x1, y0); let bottom_right = vertex(x1, y1); let bottom_left = vertex(x0, y1); out.extend_from_slice(&[ top_left, top_right, bottom_right, top_left, bottom_right, bottom_left, ]); } } } fn gradient_t(direction: LinearGradientDirection, x: f32, y: f32, width: f32, height: f32) -> f32 { match direction { LinearGradientDirection::ToBottom => y, LinearGradientDirection::ToTop => 1.0 - y, LinearGradientDirection::ToRight => x, LinearGradientDirection::ToLeft => 1.0 - x, LinearGradientDirection::ToBottomRight => { diagonal_gradient_t(x, y, width, height, 1.0, 1.0) } LinearGradientDirection::ToBottomLeft => { diagonal_gradient_t(x, y, width, height, -1.0, 1.0) } LinearGradientDirection::ToTopRight => diagonal_gradient_t(x, y, width, height, 1.0, -1.0), LinearGradientDirection::ToTopLeft => diagonal_gradient_t(x, y, width, height, -1.0, -1.0), } } fn diagonal_gradient_t(x: f32, y: f32, width: f32, height: f32, dir_x: f32, dir_y: f32) -> f32 { let span = width.abs() * dir_x.abs() + height.abs() * dir_y.abs(); if span <= f32::EPSILON { return 0.0; } let projection = (x - 0.5) * width * dir_x + (y - 0.5) * height * dir_y; ((projection + span * 0.5) / span).clamp(0.0, 1.0) } #[cfg(test)] fn gradient_color_at(gradient: &LinearGradient, t: f32) -> Color { gradient_color_at_resolved(&resolve_linear_gradient_stops(gradient), t) } fn resolve_linear_gradient_stops(gradient: &LinearGradient) -> Vec { resolve_gradient_stop_list(&gradient.stops) } fn resolve_radial_gradient_stops(gradient: &RadialGradient) -> Vec { resolve_gradient_stop_list(&gradient.stops) } fn resolve_gradient_stop_list(stops: &[GradientStop]) -> Vec { let len = stops.len(); if len == 0 { return Vec::new(); } if len == 1 { return vec![ResolvedGradientStop { color: stops[0].color, position: stops[0].position.unwrap_or(0.0), }]; } let mut positions: Vec> = stops.iter().map(|s| s.position).collect(); if positions[0].is_none() { positions[0] = Some(0.0); } if positions[len - 1].is_none() { positions[len - 1] = Some(1.0); } let mut i = 0; while i < len { if positions[i].is_some() { i += 1; continue; } let run_start = i; while i < len && positions[i].is_none() { i += 1; } let run_end = i; let prev = positions[run_start - 1].unwrap_or(0.0); let next = positions[run_end].unwrap_or(prev); let count = (run_end - run_start + 1) as f32; for (offset, slot) in positions[run_start..run_end].iter_mut().enumerate() { let step = (offset as f32 + 1.0) / count; *slot = Some(prev + (next - prev) * step); } } let mut last = f32::NEG_INFINITY; stops .iter() .zip(positions) .map(|(stop, position)| { let mut position = position.unwrap_or(last.max(0.0)); if position < last { position = last; } last = position; ResolvedGradientStop { color: stop.color, position, } }) .collect() } fn gradient_color_at_resolved(stops: &[ResolvedGradientStop], t: f32) -> Color { let Some(first) = stops.first() else { return Color::new(0, 0, 0, 0); }; if t <= first.position { return first.color; } for pair in stops.windows(2) { let a = pair[0]; let b = pair[1]; if t <= b.position { let span = b.position - a.position; if span.abs() <= f32::EPSILON { return b.color; } let local = ((t - a.position) / span).clamp(0.0, 1.0); return lerp_color(a.color, b.color, local); } } stops.last().map(|s| s.color).unwrap_or(first.color) } fn gradient_color_at_interval( stops: &[ResolvedGradientStop], t: f32, interval_start: f32, interval_end: f32, ) -> Color { let t = if (t - interval_start).abs() <= GRADIENT_EPSILON && has_repeated_gradient_stop_at(stops, interval_start) { (t + GRADIENT_EPSILON).min(interval_end) } else { t }; gradient_color_at_resolved(stops, t) } fn has_repeated_gradient_stop_at(stops: &[ResolvedGradientStop], position: f32) -> bool { stops.windows(2).any(|pair| { (pair[0].position - position).abs() <= GRADIENT_EPSILON && (pair[1].position - position).abs() <= GRADIENT_EPSILON }) } fn clip_gradient_interval( direction: LinearGradientDirection, width: f32, height: f32, min_t: f32, max_t: f32, ) -> Vec { let polygon = vec![ GradientPoint { x: 0.0, y: 0.0 }, GradientPoint { x: 1.0, y: 0.0 }, GradientPoint { x: 1.0, y: 1.0 }, GradientPoint { x: 0.0, y: 1.0 }, ]; let polygon = clip_gradient_half_plane(polygon, direction, width, height, min_t, true); clip_gradient_half_plane(polygon, direction, width, height, max_t, false) } fn clip_gradient_half_plane( polygon: Vec, direction: LinearGradientDirection, width: f32, height: f32, boundary: f32, keep_greater: bool, ) -> Vec { if polygon.is_empty() { return polygon; } let mut out = Vec::new(); let mut prev = *polygon.last().unwrap(); let mut prev_t = gradient_t(direction, prev.x, prev.y, width, height); let mut prev_inside = gradient_inside(prev_t, boundary, keep_greater); for current in polygon { let current_t = gradient_t(direction, current.x, current.y, width, height); let current_inside = gradient_inside(current_t, boundary, keep_greater); if current_inside != prev_inside { if let Some(intersection) = gradient_boundary_intersection(prev, prev_t, current, current_t, boundary) { out.push(intersection); } } if current_inside { out.push(current); } prev = current; prev_t = current_t; prev_inside = current_inside; } out } fn gradient_inside(t: f32, boundary: f32, keep_greater: bool) -> bool { if keep_greater { t >= boundary - GRADIENT_EPSILON } else { t <= boundary + GRADIENT_EPSILON } } fn gradient_boundary_intersection( a: GradientPoint, a_t: f32, b: GradientPoint, b_t: f32, boundary: f32, ) -> Option { let denom = b_t - a_t; if denom.abs() <= f32::EPSILON { return None; } let local = ((boundary - a_t) / denom).clamp(0.0, 1.0); Some(GradientPoint { x: a.x + (b.x - a.x) * local, y: a.y + (b.y - a.y) * local, }) } fn lerp_color(a: Color, b: Color, t: f32) -> Color { let alpha_a = a.a as f32 / 255.0; let alpha_b = b.a as f32 / 255.0; let alpha = alpha_a + (alpha_b - alpha_a) * t; let out_alpha = (alpha * 255.0).round().clamp(0.0, 255.0) as u8; if alpha <= f32::EPSILON { return Color::new(0, 0, 0, 0); } fn premul_channel(a: u8, alpha_a: f32, b: u8, alpha_b: f32, alpha: f32, t: f32) -> u8 { let premul_a = a as f32 * alpha_a; let premul_b = b as f32 * alpha_b; ((premul_a + (premul_b - premul_a) * t) / alpha) .round() .clamp(0.0, 255.0) as u8 } Color::new( premul_channel(a.r, alpha_a, b.r, alpha_b, alpha, t), premul_channel(a.g, alpha_a, b.g, alpha_b, alpha, t), premul_channel(a.b, alpha_a, b.b, alpha_b, alpha, t), out_alpha, ) } /// Push 6 vertices (2 triangles) covering a rounded rectangle's bounding box, /// tagged for the fragment shader's signed-distance-field rounded-rect mode. /// `radii` is (top-left, top-right, bottom-right, bottom-left). `stroke_width` /// of `0.0` fills the interior; a positive value strokes an inward ring. #[allow(clippy::too_many_arguments)] fn push_sdf_rounded_rect( out: &mut Vec, x: f32, y: f32, w: f32, h: f32, radii: [f32; 4], stroke_width: f32, color: [f32; 4], ) { let x0 = x; let y0 = y; let x1 = x + w; let y1 = y + h; let half = [w * 0.5, h * 0.5]; // Fragment-local position is measured from the rect center, so the SDF is // evaluated about the origin regardless of any layer coordinate offset. let cx = x + half[0]; let cy = y + half[1]; let vert = |px: f32, py: f32| Vertex { position: [px, py], color, tex_coord: [0.0, 0.0], use_texture: TEXTURE_MODE_SDF, local_pos: [px - cx, py - cy], rect_half: half, corner_radii: radii, stroke_width, }; // Triangle 1: TL, TR, BL out.push(vert(x0, y0)); out.push(vert(x1, y0)); out.push(vert(x0, y1)); // Triangle 2: TR, BR, BL out.push(vert(x1, y0)); out.push(vert(x1, y1)); out.push(vert(x0, y1)); } /// Push 6 vertices for an analytic blurred rounded-rect shadow. /// /// The input rect is the shadow-casting shape after CSS spread has been /// applied. The generated quad is expanded by the blur radius so the shader has /// room to shade the soft falloff outside the shape. #[allow(clippy::too_many_arguments)] fn push_sdf_box_shadow( out: &mut Vec, x: f32, y: f32, w: f32, h: f32, radii: [f32; 4], blur_radius: f32, color: [f32; 4], ) { if w <= 0.0 || h <= 0.0 { return; } let blur = blur_radius.max(0.0); let pad = blur + 1.0; let qx0 = x - pad; let qy0 = y - pad; let qx1 = x + w + pad; let qy1 = y + h + pad; let half = [w * 0.5, h * 0.5]; let cx = x + half[0]; let cy = y + half[1]; let vert = |px: f32, py: f32| Vertex { position: [px, py], color, tex_coord: [0.0, 0.0], use_texture: TEXTURE_MODE_SHADOW, local_pos: [px - cx, py - cy], rect_half: half, corner_radii: radii, stroke_width: blur, }; out.push(vert(qx0, qy0)); out.push(vert(qx1, qy0)); out.push(vert(qx0, qy1)); out.push(vert(qx1, qy0)); out.push(vert(qx1, qy1)); out.push(vert(qx0, qy1)); } /// Push 6 vertices (2 triangles) for a textured glyph quad. #[cfg(test)] fn push_textured_quad(out: &mut Vec, quad: &TexturedQuad, color: [f32; 4]) { push_textured_quad_offset(out, quad, color, (0.0, 0.0)); } /// Push 6 vertices for a textured glyph quad with a coordinate offset. fn push_textured_quad_offset( out: &mut Vec, quad: &TexturedQuad, color: [f32; 4], offset: (f32, f32), ) { let x0 = quad.x + offset.0; let y0 = quad.y + offset.1; let x1 = x0 + quad.width; let y1 = y0 + quad.height; let u0 = quad.u0; let v0 = quad.v0; let u1 = quad.u1; let v1 = quad.v1; // Triangle 1: TL, TR, BL out.push(Vertex { position: [x0, y0], color, tex_coord: [u0, v0], use_texture: TEXTURE_MODE_MASK, ..Vertex::default() }); out.push(Vertex { position: [x1, y0], color, tex_coord: [u1, v0], use_texture: TEXTURE_MODE_MASK, ..Vertex::default() }); out.push(Vertex { position: [x0, y1], color, tex_coord: [u0, v1], use_texture: TEXTURE_MODE_MASK, ..Vertex::default() }); // Triangle 2: TR, BR, BL out.push(Vertex { position: [x1, y0], color, tex_coord: [u1, v0], use_texture: TEXTURE_MODE_MASK, ..Vertex::default() }); out.push(Vertex { position: [x1, y1], color, tex_coord: [u1, v1], use_texture: TEXTURE_MODE_MASK, ..Vertex::default() }); out.push(Vertex { position: [x0, y1], color, tex_coord: [u0, v1], use_texture: TEXTURE_MODE_MASK, ..Vertex::default() }); } /// Push 6 vertices (2 triangles) for a textured image quad. fn push_image_quad(out: &mut Vec, x: f32, y: f32, w: f32, h: f32) { push_image_quad_uv(out, x, y, w, h, 0.0, 0.0, 1.0, 1.0); } #[derive(Debug, Clone, Copy)] struct FittedImageQuad { x: f32, y: f32, width: f32, height: f32, u0: f32, v0: f32, u1: f32, v1: f32, } fn resolve_object_fit( fit: ObjectFit, x: f32, y: f32, width: f32, height: f32, image_width: f32, image_height: f32, ) -> FittedImageQuad { if width <= 0.0 || height <= 0.0 || image_width <= 0.0 || image_height <= 0.0 { return FittedImageQuad { x, y, width: width.max(0.0), height: height.max(0.0), u0: 0.0, v0: 0.0, u1: 1.0, v1: 1.0, }; } match fit { ObjectFit::Fill => FittedImageQuad { x, y, width, height, u0: 0.0, v0: 0.0, u1: 1.0, v1: 1.0, }, ObjectFit::Contain => { let scale = (width / image_width).min(height / image_height); let draw_w = image_width * scale; let draw_h = image_height * scale; FittedImageQuad { x: x + (width - draw_w) * 0.5, y: y + (height - draw_h) * 0.5, width: draw_w, height: draw_h, u0: 0.0, v0: 0.0, u1: 1.0, v1: 1.0, } } ObjectFit::Cover => { let box_aspect = width / height; let image_aspect = image_width / image_height; if image_aspect > box_aspect { let visible_fraction = (box_aspect / image_aspect).clamp(0.0, 1.0); let crop = (1.0 - visible_fraction) * 0.5; FittedImageQuad { x, y, width, height, u0: crop, v0: 0.0, u1: 1.0 - crop, v1: 1.0, } } else { let visible_fraction = (image_aspect / box_aspect).clamp(0.0, 1.0); let crop = (1.0 - visible_fraction) * 0.5; FittedImageQuad { x, y, width, height, u0: 0.0, v0: crop, u1: 1.0, v1: 1.0 - crop, } } } } } /// Push 6 vertices (2 triangles) for a textured image quad with explicit UVs. #[allow(clippy::too_many_arguments)] fn push_image_quad_uv( out: &mut Vec, x: f32, y: f32, w: f32, h: f32, u0: f32, v0: f32, u1: f32, v1: f32, ) { let x0 = x; let y0 = y; let x1 = x + w; let y1 = y + h; let color = [1.0, 1.0, 1.0, 1.0]; // no tint // Triangle 1: TL, TR, BL out.push(Vertex { position: [x0, y0], color, tex_coord: [u0, v0], use_texture: TEXTURE_MODE_COLOR, ..Vertex::default() }); out.push(Vertex { position: [x1, y0], color, tex_coord: [u1, v0], use_texture: TEXTURE_MODE_COLOR, ..Vertex::default() }); out.push(Vertex { position: [x0, y1], color, tex_coord: [u0, v1], use_texture: TEXTURE_MODE_COLOR, ..Vertex::default() }); // Triangle 2: TR, BR, BL out.push(Vertex { position: [x1, y0], color, tex_coord: [u1, v0], use_texture: TEXTURE_MODE_COLOR, ..Vertex::default() }); out.push(Vertex { position: [x1, y1], color, tex_coord: [u1, v1], use_texture: TEXTURE_MODE_COLOR, ..Vertex::default() }); out.push(Vertex { position: [x0, y1], color, tex_coord: [u0, v1], use_texture: TEXTURE_MODE_COLOR, ..Vertex::default() }); } /// Push 6 vertices for compositing a layer texture back onto the parent target. /// /// The layer texture is drawn as a full quad at the layer's screen position. /// The `color` alpha channel carries the layer opacity. fn push_layer_quad(out: &mut Vec, x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) { let x0 = x; let y0 = y; let x1 = x + w; let y1 = y + h; // Triangle 1: TL, TR, BL out.push(Vertex { position: [x0, y0], color, tex_coord: [0.0, 0.0], use_texture: TEXTURE_MODE_COLOR, ..Vertex::default() }); out.push(Vertex { position: [x1, y0], color, tex_coord: [1.0, 0.0], use_texture: TEXTURE_MODE_COLOR, ..Vertex::default() }); out.push(Vertex { position: [x0, y1], color, tex_coord: [0.0, 1.0], use_texture: TEXTURE_MODE_COLOR, ..Vertex::default() }); // Triangle 2: TR, BR, BL out.push(Vertex { position: [x1, y0], color, tex_coord: [1.0, 0.0], use_texture: TEXTURE_MODE_COLOR, ..Vertex::default() }); out.push(Vertex { position: [x1, y1], color, tex_coord: [1.0, 1.0], use_texture: TEXTURE_MODE_COLOR, ..Vertex::default() }); out.push(Vertex { position: [x0, y1], color, tex_coord: [0.0, 1.0], use_texture: TEXTURE_MODE_COLOR, ..Vertex::default() }); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Convert a CSS RGBA color to a float array [r, g, b, a] in 0.0–1.0. fn color_to_f32(c: &Color) -> [f32; 4] { [ c.r as f32 / 255.0, c.g as f32 / 255.0, c.b as f32 / 255.0, c.a as f32 / 255.0, ] } /// Upload an image to the GPU as an RGBA8 texture. fn upload_image(device: &Device, img: &Image) -> Option { if img.width == 0 || img.height == 0 || img.data.is_empty() { return None; } // Convert RGBA to BGRA for Metal's preferred pixel format. let mut bgra = Vec::with_capacity(img.data.len()); for chunk in img.data.chunks_exact(4) { bgra.push(chunk[2]); // B bgra.push(chunk[1]); // G bgra.push(chunk[0]); // R bgra.push(chunk[3]); // A } device.new_texture( img.width, img.height, MTL_PIXEL_FORMAT_BGRA8_UNORM, &bgra, img.width * 4, ) } /// Apply a scissor rect to the encoder, clamping to valid dimensions. fn apply_scissor(encoder: &RenderCommandEncoder, rect: ScissorRect, vw: u32, vh: u32) { // Metal requires scissor rect to be within the render target. let x = rect.x.min(vw); let y = rect.y.min(vh); let w = rect.width.min(vw.saturating_sub(x)).max(1); let h = rect.height.min(vh.saturating_sub(y)).max(1); encoder.set_scissor_rect(x as u64, y as u64, w as u64, h as u64); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn scissor_rect_full() { let r = ScissorRect::full(800, 600); assert_eq!(r.x, 0); assert_eq!(r.y, 0); assert_eq!(r.width, 800); assert_eq!(r.height, 600); } #[test] fn scissor_rect_intersect() { let a = ScissorRect { x: 0, y: 0, width: 100, height: 100, }; let b = ScissorRect { x: 50, y: 50, width: 100, height: 100, }; let c = a.intersect(b); assert_eq!(c.x, 50); assert_eq!(c.y, 50); assert_eq!(c.width, 50); assert_eq!(c.height, 50); } #[test] fn scissor_rect_no_overlap() { let a = ScissorRect { x: 0, y: 0, width: 50, height: 50, }; let b = ScissorRect { x: 100, y: 100, width: 50, height: 50, }; let c = a.intersect(b); assert_eq!(c.width, 0); assert_eq!(c.height, 0); } #[test] fn color_conversion() { let c = Color { r: 255, g: 128, b: 0, a: 255, }; let f = color_to_f32(&c); assert!((f[0] - 1.0).abs() < 0.01); assert!((f[1] - 0.502).abs() < 0.01); assert!((f[2] - 0.0).abs() < 0.01); assert!((f[3] - 1.0).abs() < 0.01); } #[test] fn gradient_color_at_uses_intermediate_stops() { let gradient = LinearGradient { direction: LinearGradientDirection::ToBottom, stops: vec![ we_css::values::GradientStop { color: Color::rgb(255, 0, 0), position: Some(0.0), }, we_css::values::GradientStop { color: Color::rgb(0, 255, 0), position: Some(0.5), }, we_css::values::GradientStop { color: Color::rgb(0, 0, 255), position: Some(1.0), }, ], }; assert_eq!(gradient_color_at(&gradient, 0.0), Color::rgb(255, 0, 0)); assert_eq!(gradient_color_at(&gradient, 0.5), Color::rgb(0, 255, 0)); assert_eq!(gradient_color_at(&gradient, 1.0), Color::rgb(0, 0, 255)); } #[test] fn gradient_color_at_interpolates_transparency_premultiplied() { let gradient = LinearGradient { direction: LinearGradientDirection::ToBottom, stops: vec![ we_css::values::GradientStop { color: Color::new(100, 50, 200, 128), position: Some(0.0), }, we_css::values::GradientStop { color: Color::new(0, 0, 0, 0), position: Some(1.0), }, ], }; assert_eq!( gradient_color_at(&gradient, 0.5), Color::new(100, 50, 200, 64) ); } #[test] fn unspecified_gradient_stops_are_evenly_distributed() { let gradient = LinearGradient { direction: LinearGradientDirection::ToBottom, stops: vec![ we_css::values::GradientStop { color: Color::rgb(255, 0, 0), position: None, }, we_css::values::GradientStop { color: Color::rgb(0, 255, 0), position: None, }, we_css::values::GradientStop { color: Color::rgb(0, 0, 255), position: None, }, ], }; let stops = resolve_linear_gradient_stops(&gradient); assert_eq!(stops.len(), 3); assert!((stops[0].position - 0.0).abs() < 0.001); assert!((stops[1].position - 0.5).abs() < 0.001); assert!((stops[2].position - 1.0).abs() < 0.001); } #[test] fn repeated_gradient_stop_uses_right_color_for_next_interval() { let gradient = LinearGradient { direction: LinearGradientDirection::ToBottom, stops: vec![ we_css::values::GradientStop { color: Color::rgb(255, 0, 0), position: Some(0.0), }, we_css::values::GradientStop { color: Color::rgb(0, 255, 0), position: Some(0.5), }, we_css::values::GradientStop { color: Color::rgb(0, 0, 0), position: Some(0.5), }, we_css::values::GradientStop { color: Color::rgb(255, 255, 255), position: Some(1.0), }, ], }; let stops = resolve_linear_gradient_stops(&gradient); assert_eq!( gradient_color_at_interval(&stops, 0.5, 0.0, 0.5), Color::rgb(0, 255, 0) ); assert_eq!( gradient_color_at_interval(&stops, 0.5, 0.5, 1.0), Color::rgb(0, 0, 0) ); } #[test] fn diagonal_gradient_coordinates_use_rect_aspect_ratio() { let square_t = gradient_t( LinearGradientDirection::ToBottomRight, 1.0, 0.0, 100.0, 100.0, ); let wide_t = gradient_t( LinearGradientDirection::ToBottomRight, 1.0, 0.0, 300.0, 100.0, ); let tall_t = gradient_t( LinearGradientDirection::ToBottomRight, 1.0, 0.0, 100.0, 300.0, ); assert!((square_t - 0.5).abs() < 0.001); assert!((wide_t - 0.75).abs() < 0.001); assert!((tall_t - 0.25).abs() < 0.001); } #[test] fn diagonal_gradient_interval_clip_uses_rect_aspect_ratio() { let square = clip_gradient_interval( LinearGradientDirection::ToBottomRight, 100.0, 100.0, 0.5, 1.0, ); let wide = clip_gradient_interval( LinearGradientDirection::ToBottomRight, 300.0, 100.0, 0.75, 1.0, ); assert!(square .iter() .any(|p| (p.x - 1.0).abs() < 0.001 && (p.y - 0.0).abs() < 0.001)); assert!(wide .iter() .any(|p| (p.x - 1.0).abs() < 0.001 && (p.y - 0.0).abs() < 0.001)); } #[test] fn positioned_gradient_stop_splits_quad() { let gradient = LinearGradient { direction: LinearGradientDirection::ToBottom, stops: vec![ we_css::values::GradientStop { color: Color::new(0, 0, 0, 0), position: Some(0.6), }, we_css::values::GradientStop { color: Color::new(0, 0, 0, 255), position: Some(1.0), }, ], }; let mut verts = Vec::new(); push_linear_gradient_quad(&mut verts, 0.0, 0.0, 100.0, 100.0, &gradient); assert_eq!(verts.len(), 12); assert!( verts.iter().any(|v| (v.position[1] - 60.0).abs() < 0.01), "expected a split at the 60% stop" ); assert!(verts .iter() .filter(|v| v.position[1] <= 60.01) .all(|v| v.color[3].abs() < 0.01)); assert!(verts .iter() .any(|v| (v.position[1] - 100.0).abs() < 0.01 && v.color[3] > 0.99)); } #[test] fn solid_quad_vertex_count() { let mut verts = Vec::new(); push_solid_quad(&mut verts, 0.0, 0.0, 100.0, 50.0, [1.0, 0.0, 0.0, 1.0]); assert_eq!(verts.len(), 6); // Solid quads do not sample a texture. for v in &verts { assert_eq!(v.use_texture, TEXTURE_MODE_SOLID); } } #[test] fn sdf_box_shadow_vertices_expand_for_blur() { let mut verts = Vec::new(); push_sdf_box_shadow( &mut verts, 10.0, 20.0, 100.0, 50.0, [8.0; 4], 12.0, [0.0, 0.0, 0.0, 0.5], ); assert_eq!(verts.len(), 6); assert!(verts.iter().all(|v| v.use_texture == TEXTURE_MODE_SHADOW)); assert!(verts.iter().all(|v| v.stroke_width == 12.0)); assert!(verts.iter().any(|v| v.position == [-3.0, 7.0])); assert!(verts.iter().any(|v| v.position == [123.0, 83.0])); assert!(verts.iter().all(|v| v.rect_half == [50.0, 25.0])); } fn test_radial_gradient() -> RadialGradient { RadialGradient { stops: vec![ GradientStop { color: Color::new(87, 41, 255, 59), position: Some(0.0), }, GradientStop { color: Color::new(0, 0, 0, 0), position: Some(100.0), }, ], } } #[test] fn shifted_no_repeat_radial_gradient_clips_to_positioned_image() { let mut verts = Vec::new(); push_radial_gradient_quad( &mut verts, (0.0, 0.0, 100.0, 50.0), (LengthOrAuto::Length(60.0), LengthOrAuto::Length(0.0)), BackgroundRepeat::NoRepeat, &test_radial_gradient(), ); assert!(!verts.is_empty()); let min_x = verts .iter() .map(|v| v.position[0]) .fold(f32::INFINITY, f32::min); let max_x = verts .iter() .map(|v| v.position[0]) .fold(f32::NEG_INFINITY, f32::max); assert!((min_x - 60.0).abs() < 0.001); assert!((max_x - 100.0).abs() < 0.001); } #[test] fn shifted_repeating_radial_gradient_keeps_full_paint_area() { let mut verts = Vec::new(); push_radial_gradient_quad( &mut verts, (0.0, 0.0, 100.0, 50.0), (LengthOrAuto::Length(60.0), LengthOrAuto::Length(0.0)), BackgroundRepeat::Repeat, &test_radial_gradient(), ); assert!(!verts.is_empty()); let min_x = verts .iter() .map(|v| v.position[0]) .fold(f32::INFINITY, f32::min); let max_x = verts .iter() .map(|v| v.position[0]) .fold(f32::NEG_INFINITY, f32::max); assert!((min_x - 0.0).abs() < 0.001); assert!((max_x - 100.0).abs() < 0.001); } #[test] fn textured_quad_vertex_count() { let quad = TexturedQuad { x: 10.0, y: 20.0, width: 8.0, height: 12.0, u0: 0.0, v0: 0.0, u1: 0.5, v1: 0.5, color: [1.0, 1.0, 1.0, 1.0], page: 0, }; let mut verts = Vec::new(); push_textured_quad(&mut verts, &quad, [1.0, 1.0, 1.0, 1.0]); assert_eq!(verts.len(), 6); // Glyph atlas quads sample R8 masks, not color textures. for v in &verts { assert_eq!(v.use_texture, TEXTURE_MODE_MASK); } } #[test] fn image_quad_vertex_count() { let mut verts = Vec::new(); push_image_quad(&mut verts, 0.0, 0.0, 200.0, 150.0); assert_eq!(verts.len(), 6); for v in &verts { assert_eq!(v.use_texture, TEXTURE_MODE_COLOR); } // Check UV coordinates cover full image (0,0)→(1,1) assert_eq!(verts[0].tex_coord, [0.0, 0.0]); // TL assert_eq!(verts[1].tex_coord, [1.0, 0.0]); // TR assert_eq!(verts[4].tex_coord, [1.0, 1.0]); // BR } #[test] fn object_fit_cover_crops_wide_source_horizontally() { let fitted = resolve_object_fit(ObjectFit::Cover, 0.0, 0.0, 100.0, 100.0, 200.0, 100.0); assert_eq!(fitted.x, 0.0); assert_eq!(fitted.y, 0.0); assert_eq!(fitted.width, 100.0); assert_eq!(fitted.height, 100.0); assert!((fitted.u0 - 0.25).abs() < 0.001); assert!((fitted.u1 - 0.75).abs() < 0.001); assert_eq!(fitted.v0, 0.0); assert_eq!(fitted.v1, 1.0); } #[test] fn object_fit_contain_centers_letterboxed_image() { let fitted = resolve_object_fit(ObjectFit::Contain, 10.0, 20.0, 100.0, 100.0, 200.0, 100.0); assert_eq!(fitted.x, 10.0); assert_eq!(fitted.width, 100.0); assert!((fitted.y - 45.0).abs() < 0.001); assert!((fitted.height - 50.0).abs() < 0.001); assert_eq!(fitted.u0, 0.0); assert_eq!(fitted.v0, 0.0); assert_eq!(fitted.u1, 1.0); assert_eq!(fitted.v1, 1.0); } #[test] fn texture_key_equality() { assert_eq!(TextureKey::Solid, TextureKey::Solid); assert_eq!(TextureKey::AtlasPage(0), TextureKey::AtlasPage(0)); assert_ne!(TextureKey::AtlasPage(0), TextureKey::AtlasPage(1)); assert_ne!(TextureKey::Solid, TextureKey::AtlasPage(0)); assert_ne!(TextureKey::Solid, TextureKey::Image(0)); } #[test] fn layer_quad_vertex_count() { let mut verts = Vec::new(); push_layer_quad(&mut verts, 10.0, 20.0, 100.0, 50.0, [1.0, 1.0, 1.0, 0.5]); assert_eq!(verts.len(), 6); for v in &verts { assert_eq!(v.use_texture, TEXTURE_MODE_COLOR); assert_eq!(v.color[3], 0.5); } assert_eq!(verts[0].tex_coord, [0.0, 0.0]); // TL assert_eq!(verts[1].tex_coord, [1.0, 0.0]); // TR assert_eq!(verts[4].tex_coord, [1.0, 1.0]); // BR } #[test] fn textured_quad_with_offset() { let quad = TexturedQuad { x: 100.0, y: 200.0, width: 10.0, height: 14.0, u0: 0.0, v0: 0.0, u1: 1.0, v1: 1.0, color: [1.0, 1.0, 1.0, 1.0], page: 0, }; let mut verts = Vec::new(); push_textured_quad_offset(&mut verts, &quad, [1.0, 1.0, 1.0, 1.0], (-50.0, -100.0)); assert_eq!(verts.len(), 6); // TL position should be offset assert_eq!(verts[0].position, [50.0, 100.0]); // BR position assert_eq!(verts[4].position, [60.0, 114.0]); } }