//! [`UiOverlayPass`]: batches Stage-8 UI [`DrawCommand`]s into one render pass. //! //! Slots into the Stage-5 [`RenderPipeline`](super::RenderPipeline) **after** //! the forward pass (so UI draws on top of the 3D scene) and **before** //! any future post-process. It consumes a list of //! [`UiBatch`]es per frame — each carries its own MVP matrix and a flat //! [`PaintedFrame`] of draw commands — uploads the CPU glyph atlas to a //! single R8 texture (re-uploading only on dirty), and submits one draw call //! per batch (vertices buffered into a single growable vertex buffer). //! //! # Why batches //! //! The same pipeline draws **screen-space UI** (the host adds one batch //! whose MVP is an orthographic projection from window pixels to NDC) and //! **world-space UI** (piece 4b adds one batch per `UiPanel`, each with its //! own world-to-clip MVP). The vertex format is identical; the only thing //! that differs is the MVP — and that's a small per-batch uniform update, //! so the GPU pipeline never has to switch state between a HUD and a //! diegetic panel. //! //! # Test strategy //! //! [`Gpu::headless()`](super::Gpu::headless) gives us a no-window device. //! The pass renders into an offscreen `Rgba8Unorm` texture; the host reads //! pixels back via a copy buffer and asserts on them. The Stage-4 //! `lit_sphere_renders_over_background` test pattern carries over directly — //! a UI batch whose only command is a `Quad { rect, color: RED }` should //! produce red pixels inside that rect and the clear color outside it. Tests //! that need text load a system font via //! [`common_system_font_paths`](super::super::ui::text::common_system_font_paths) //! and skip gracefully on hosts without one. use std::num::NonZeroU64; use bytemuck::{Pod, Zeroable}; use glam::{Mat4, Vec2, Vec3, Vec4}; use super::pipeline::{FrameContext, RenderPass}; use crate::math::{Color, Rect, Transform}; use crate::ui::paint::{DrawCommand, PaintedFrame}; use crate::ui::text::{FontStore, GlyphAtlas}; /// One batch of UI to draw with a single MVP — either a screen-space tree or /// a world-space panel. pub struct UiBatch { /// Clip-space matrix applied to every vertex in this batch's commands. pub mvp: Mat4, /// The painted commands, in submission order (back-to-front). pub frame: PaintedFrame, } impl UiBatch { /// Screen-space batch: maps pixel coordinates `(0, 0)..(width, height)` /// to NDC with y-down (origin at the top-left, matching UI convention). pub fn screen_space(frame: PaintedFrame, target_size: (u32, u32)) -> Self { let (w, h) = (target_size.0.max(1) as f32, target_size.1.max(1) as f32); // ortho(left, right, bottom, top, near, far) // For y-down with origin at the top-left: bottom = h, top = 0. let mvp = Mat4::orthographic_rh(0.0, w, h, 0.0, -1.0, 1.0); Self { mvp, frame } } /// World-space batch: place a panel's UI inside 3D world space. /// /// The painted frame's vertices are in **panel-pixel** coordinates /// (`(0, 0)..=pixel_size`). This constructor composes the MVP that /// maps each vertex through: /// /// 1. Recenter the pixel origin to the panel's centre (so the pixel /// midpoint maps to the panel's local origin). /// 2. Scale pixels → world units using `world_size / pixel_size`, with /// the y axis **negated** because UI is y-down but world is y-up. /// 3. Apply `panel_transform` (the panel's world placement). /// 4. Apply `view_projection` (the camera's clip-space matrix). /// /// The end-to-end effect: a pixel at `(0, 0)` in the painted frame /// lands at world position `panel_transform * (-world.x/2, +world.y/2, /// 0)` (the panel's top-left corner); a pixel at `pixel_size` lands /// at the panel's bottom-right. pub fn world_space( frame: PaintedFrame, pixel_size: Vec2, world_size: Vec2, panel_transform: &Transform, view_projection: Mat4, ) -> Self { let pixel_to_centered = Mat4::from_translation(Vec3::new(-pixel_size.x * 0.5, -pixel_size.y * 0.5, 0.0)); let centered_to_world_local = Mat4::from_scale(Vec3::new( world_size.x / pixel_size.x.max(1.0), -world_size.y / pixel_size.y.max(1.0), // y-down → y-up 1.0, )); let world_local_to_world = panel_transform.to_matrix(); let mvp = view_projection * world_local_to_world * centered_to_world_local * pixel_to_centered; Self { mvp, frame } } } /// A render pass that draws Stage-8 UI batches over the existing color /// target. pub struct UiOverlayPass { pipeline: wgpu::RenderPipeline, bind_group: wgpu::BindGroup, atlas_texture: wgpu::Texture, // Held to keep the texture view alive while the bind group references // it (wgpu Arc-counts internally, but storing it here makes the // ownership explicit). _atlas_view: wgpu::TextureView, atlas_size: (u32, u32), _atlas_sampler: wgpu::Sampler, uniform_buffer: wgpu::Buffer, vertex_buffer: wgpu::Buffer, vertex_capacity: u64, cpu_atlas: GlyphAtlas, fonts: FontStore, pending: Vec, } #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct UiUniform { mvp: [[f32; 4]; 4], } #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct UiVertex { position: [f32; 2], uv: [f32; 2], color: [f32; 4], } impl UiVertex { const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &wgpu::vertex_attr_array![ 0 => Float32x2, // position 1 => Float32x2, // uv 2 => Float32x4, // color ], }; } const DEFAULT_ATLAS_SIZE: u32 = 1024; const DEFAULT_VERTEX_CAPACITY: u64 = 4096; /// Sentinel UV for solid quads. The shader treats any `uv.x < 0.0` as /// "skip atlas sample" — see `engine/src/render/shaders/ui.wgsl`. const SOLID_UV: Vec2 = Vec2::new(-1.0, -1.0); impl UiOverlayPass { /// Build a pass for the given color target format. Initialises a /// 1024×1024 R8 atlas, the pipeline, and the bind group; the host wires /// it into [`RenderPipeline`](super::RenderPipeline) with /// `add_pass("ui", pass)` *after* the forward pass. pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self { Self::with_atlas_size(device, color_format, DEFAULT_ATLAS_SIZE, DEFAULT_ATLAS_SIZE) } /// Build a pass with an explicit atlas resolution — useful in tests /// where a 1024×1024 atlas is overkill. pub fn with_atlas_size( device: &wgpu::Device, color_format: wgpu::TextureFormat, atlas_w: u32, atlas_h: u32, ) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("oxide.ui.shader"), source: wgpu::ShaderSource::Wgsl(include_str!("shaders/ui.wgsl").into()), }); let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("oxide.ui.bind_group_layout"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: NonZeroU64::new(std::mem::size_of::() as u64), }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, ], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("oxide.ui.pipeline_layout"), bind_group_layouts: &[Some(&bind_group_layout)], immediate_size: 0, }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("oxide.ui.pipeline"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_main"), compilation_options: Default::default(), buffers: &[UiVertex::LAYOUT], }, primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, // No cull — UI quads are CPU-emitted CCW but flipping the // MVP for world-space panels can swap the winding; rely on // alpha blending instead. cull_mode: None, unclipped_depth: false, polygon_mode: wgpu::PolygonMode::Fill, conservative: false, }, // UI doesn't read depth (it overlays). depth_stencil: None, multisample: wgpu::MultisampleState::default(), fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs_main"), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format: color_format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], }), multiview_mask: None, cache: None, }); let atlas_texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("oxide.ui.atlas"), size: wgpu::Extent3d { width: atlas_w, height: atlas_h, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::R8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default()); let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("oxide.ui.atlas_sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, address_mode_w: wgpu::AddressMode::ClampToEdge, mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, mipmap_filter: wgpu::MipmapFilterMode::Nearest, ..Default::default() }); let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("oxide.ui.uniform"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("oxide.ui.vertices"), size: DEFAULT_VERTEX_CAPACITY * std::mem::size_of::() as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("oxide.ui.bind_group"), layout: &bind_group_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: uniform_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&atlas_view), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&atlas_sampler), }, ], }); Self { pipeline, bind_group, atlas_texture, _atlas_view: atlas_view, atlas_size: (atlas_w, atlas_h), _atlas_sampler: atlas_sampler, uniform_buffer, vertex_buffer, vertex_capacity: DEFAULT_VERTEX_CAPACITY, cpu_atlas: GlyphAtlas::new(atlas_w, atlas_h), fonts: FontStore::new(), pending: Vec::new(), } } /// Borrow the pass's font store mutably to register fonts. Fonts /// referenced by [`DrawCommand::Glyph`] keys must already be in this /// store before the pass runs. pub fn fonts_mut(&mut self) -> &mut FontStore { &mut self.fonts } /// Borrow the pass's font store. Useful for shaping outside the pass /// (e.g. in [`paint`](crate::ui::paint::paint)) using the same `FontId`s. pub fn fonts(&self) -> &FontStore { &self.fonts } /// Replace the pending batches for this frame. The pass renders these on /// its next [`run`](Self::run) call and then clears them. pub fn set_batches(&mut self, batches: Vec) { self.pending = batches; } /// Number of batches currently queued for the next `run`. pub fn batch_count(&self) -> usize { self.pending.len() } /// Resolution of the CPU/GPU glyph atlas. pub fn atlas_size(&self) -> (u32, u32) { self.atlas_size } /// Number of distinct glyphs currently cached in the atlas. /// /// Useful for diagnostics: once this count stops growing across frames, /// every glyph the UI draws is a cache hit and `run` no longer rasterizes /// or re-uploads the atlas. HUD-style overlays that animate numeric values /// reach this steady state after the digits `0`–`9` (and any static /// labels) have each been seen once. pub fn atlas_glyph_count(&self) -> usize { self.cpu_atlas.len() } /// Whether the atlas gained a glyph during the most recent `run` and has /// not yet been re-uploaded. `run` clears this immediately after uploading, /// so from a host's perspective it reads `false` in steady state. pub fn atlas_dirty(&self) -> bool { self.cpu_atlas.dirty() } } impl RenderPass for UiOverlayPass { fn run(&mut self, frame: &mut FrameContext<'_>) { if self.pending.is_empty() { return; } // Step 1: walk every glyph in every batch to ensure the atlas has // their entries. This is the only step that can mutate `cpu_atlas` // and the only step that may raise the dirty flag. for batch in &self.pending { for cmd in &batch.frame.commands { if let DrawCommand::Glyph { key, .. } = cmd { let _ = self.cpu_atlas.get_or_rasterize(*key, &self.fonts); } } } // Step 2: re-upload the atlas to the GPU texture if it grew. if self.cpu_atlas.dirty() { let (w, h) = self.atlas_size; frame.queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &self.atlas_texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, self.cpu_atlas.pixels(), wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w), rows_per_image: Some(h), }, wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1, }, ); self.cpu_atlas.clear_dirty(); } // Step 3: render each batch — one draw call per batch. let resolved_viewport = frame.resolved_viewport(); for batch in std::mem::take(&mut self.pending) { self.render_batch(frame, &batch, resolved_viewport); } } } impl UiOverlayPass { fn render_batch(&mut self, frame: &mut FrameContext<'_>, batch: &UiBatch, viewport_rect: Rect) { // 1. Translate draw commands into a vertex buffer. let vertices = self.commands_to_vertices(&batch.frame.commands); if vertices.is_empty() { return; } self.ensure_vertex_capacity(frame.device, vertices.len() as u64); frame .queue .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&vertices)); // 2. Update the MVP uniform. let uniform = UiUniform { mvp: batch.mvp.to_cols_array_2d(), }; frame .queue .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform)); // 3. Encode the render pass. let mut encoder = frame .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("oxide.ui.encoder"), }); { let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("oxide.ui.pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: frame.color, depth_slice: None, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); rpass.set_pipeline(&self.pipeline); rpass.set_bind_group(0, &self.bind_group, &[]); rpass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); rpass.set_viewport( viewport_rect.min.x, viewport_rect.min.y, viewport_rect.width().max(1.0), viewport_rect.height().max(1.0), 0.0, 1.0, ); rpass.draw(0..vertices.len() as u32, 0..1); } frame.queue.submit(Some(encoder.finish())); } fn commands_to_vertices(&self, commands: &[DrawCommand]) -> Vec { let mut vertices = Vec::with_capacity(commands.len() * 6); let (atlas_w, atlas_h) = (self.atlas_size.0 as f32, self.atlas_size.1 as f32); for cmd in commands { match cmd { DrawCommand::Quad { rect, color } => { push_quad( &mut vertices, rect.min, rect.max, SOLID_UV, SOLID_UV, color_to_array(*color), ); } DrawCommand::Glyph { key, pen_position, color, } => { let Some(entry) = self.cpu_atlas.get(key) else { continue; // glyph not yet rasterized (e.g., space) }; let top_left = *pen_position + entry.bearing; let bottom_right = top_left + entry.size_px; push_quad( &mut vertices, top_left, bottom_right, entry.uv_min, entry.uv_max, color_to_array(*color), ); let _ = (atlas_w, atlas_h); } } } vertices } fn ensure_vertex_capacity(&mut self, device: &wgpu::Device, needed: u64) { if needed <= self.vertex_capacity { return; } let mut new_cap = self.vertex_capacity.max(1); while new_cap < needed { new_cap *= 2; } self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("oxide.ui.vertices"), size: new_cap * std::mem::size_of::() as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); self.vertex_capacity = new_cap; } } fn push_quad( out: &mut Vec, min: Vec2, max: Vec2, uv_min: Vec2, uv_max: Vec2, color: [f32; 4], ) { // Two triangles: (TL, BL, BR), (TL, BR, TR). Counter-clockwise in // pixel coords (where y increases downward), which becomes CW after // the y-flip orthographic projection — `cull_mode: None` covers either. let tl = UiVertex { position: [min.x, min.y], uv: [uv_min.x, uv_min.y], color, }; let tr = UiVertex { position: [max.x, min.y], uv: [uv_max.x, uv_min.y], color, }; let bl = UiVertex { position: [min.x, max.y], uv: [uv_min.x, uv_max.y], color, }; let br = UiVertex { position: [max.x, max.y], uv: [uv_max.x, uv_max.y], color, }; out.push(tl); out.push(bl); out.push(br); out.push(tl); out.push(br); out.push(tr); } fn color_to_array(c: Color) -> [f32; 4] { let v: Vec4 = Vec4::new(c.r, c.g, c.b, c.a); v.to_array() } #[cfg(test)] mod tests { use super::*; use crate::math::{Color, Transform, Vec2 as MVec2}; use crate::render::{Camera, Gpu, Lighting}; use crate::ui::paint::{DrawCommand, PaintedFrame}; /// Build a headless GPU + an offscreen Rgba8 target + a [`FrameContext`] /// with sensible defaults, ready to feed a pass's `run`. fn make_headless(target_w: u32, target_h: u32) -> Option { let gpu = match Gpu::headless() { Ok(gpu) => gpu, Err(err) => { eprintln!("SKIP: no GPU adapter available ({err})"); return None; } }; Some(HeadlessHarness::new(gpu, target_w, target_h)) } struct HeadlessHarness { gpu: Gpu, target: wgpu::Texture, target_view: wgpu::TextureView, readback: wgpu::Buffer, target_size: (u32, u32), } impl HeadlessHarness { fn new(gpu: Gpu, w: u32, h: u32) -> Self { let device = gpu.device(); let target = device.create_texture(&wgpu::TextureDescriptor { label: Some("test-target"), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Unorm, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let target_view = target.create_view(&wgpu::TextureViewDescriptor::default()); let bytes_per_row = align_up(w * 4, 256); let readback = device.create_buffer(&wgpu::BufferDescriptor { label: Some("test-readback"), size: (bytes_per_row * h) as u64, usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false, }); Self { gpu, target, target_view, readback, target_size: (w, h), } } /// Read back the target's pixels as `Rgba8`. fn read_pixels(&self) -> Vec { let (w, h) = self.target_size; let bytes_per_row = align_up(w * 4, 256); let device = self.gpu.device(); let queue = self.gpu.queue(); let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("test-copy"), }); encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: &self.target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { buffer: &self.readback, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(bytes_per_row), rows_per_image: Some(h), }, }, wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1, }, ); queue.submit(Some(encoder.finish())); let slice = self.readback.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |r| { tx.send(r).unwrap(); }); device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); rx.recv().unwrap().unwrap(); let view = slice.get_mapped_range(); let mut out = Vec::with_capacity((w * h * 4) as usize); for row in 0..h { let start = (row * bytes_per_row) as usize; out.extend_from_slice(&view[start..start + (w * 4) as usize]); } drop(view); self.readback.unmap(); out } fn run_pass(&self, pass: &mut UiOverlayPass, clear: Color) { let device = self.gpu.device(); let queue = self.gpu.queue(); // Clear the target first (using a one-off render pass). let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("test-clear"), }); { let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("test-clear-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &self.target_view, depth_slice: None, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { r: clear.r as f64, g: clear.g as f64, b: clear.b as f64, a: clear.a as f64, }), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); } queue.submit(Some(encoder.finish())); // Build a `FrameContext` to feed the pass. let camera = Camera::default(); let view_transform = Transform::default(); let lighting = Lighting::default(); let mut frame = FrameContext { device, queue, color: &self.target_view, size: self.target_size, viewport_rect: None, clear_color: clear, camera: &camera, view_transform: &view_transform, lighting: &lighting, objects: &[], }; pass.run(&mut frame); } } fn align_up(x: u32, to: u32) -> u32 { x.div_ceil(to) * to } fn pixel(buf: &[u8], w: u32, x: u32, y: u32) -> (u8, u8, u8, u8) { let i = ((y * w + x) * 4) as usize; (buf[i], buf[i + 1], buf[i + 2], buf[i + 3]) } #[test] fn solid_red_quad_renders_inside_its_rect_only() { let Some(harness) = make_headless(64, 64) else { return; }; let mut pass = UiOverlayPass::with_atlas_size( harness.gpu.device(), wgpu::TextureFormat::Rgba8Unorm, 64, 64, ); // A 20×20 red rect centered in the 64×64 target. let frame = PaintedFrame { size: MVec2::new(64.0, 64.0), commands: vec![DrawCommand::Quad { rect: Rect::from_min_size(MVec2::new(22.0, 22.0), MVec2::new(20.0, 20.0)), color: Color::RED, }], }; pass.set_batches(vec![UiBatch::screen_space(frame, (64, 64))]); harness.run_pass(&mut pass, Color::rgb(0.0, 0.0, 0.2)); let pixels = harness.read_pixels(); // Center pixel (32, 32) is inside the rect → red. let (r, g, b, _a) = pixel(&pixels, 64, 32, 32); assert!(r > 200, "center pixel should be red, got r={r}"); assert!(g < 30, "center pixel should not have green, got g={g}"); assert!(b < 30, "center pixel should not have blue, got b={b}"); // Corner pixel (0, 0) is outside → the clear color (dark blue). let (r, g, b, _) = pixel(&pixels, 64, 0, 0); assert!(r < 30 && g < 30 && b > 30, "corner should be clear color"); } #[test] fn empty_batch_list_is_a_noop() { let Some(harness) = make_headless(16, 16) else { return; }; let mut pass = UiOverlayPass::with_atlas_size( harness.gpu.device(), wgpu::TextureFormat::Rgba8Unorm, 64, 64, ); // No batches queued — the run should not panic. harness.run_pass(&mut pass, Color::WHITE); let pixels = harness.read_pixels(); let (r, g, b, _) = pixel(&pixels, 16, 8, 8); assert!(r > 200 && g > 200 && b > 200, "should still be white"); } #[test] fn two_quads_in_one_batch_both_render() { let Some(harness) = make_headless(48, 32) else { return; }; let mut pass = UiOverlayPass::with_atlas_size( harness.gpu.device(), wgpu::TextureFormat::Rgba8Unorm, 64, 64, ); let frame = PaintedFrame { size: MVec2::new(48.0, 32.0), commands: vec![ DrawCommand::Quad { rect: Rect::from_min_size(MVec2::new(2.0, 2.0), MVec2::new(20.0, 28.0)), color: Color::RED, }, DrawCommand::Quad { rect: Rect::from_min_size(MVec2::new(26.0, 2.0), MVec2::new(20.0, 28.0)), color: Color::GREEN, }, ], }; pass.set_batches(vec![UiBatch::screen_space(frame, (48, 32))]); harness.run_pass(&mut pass, Color::BLACK); let pixels = harness.read_pixels(); // Left rect → red. let (r, g, b, _) = pixel(&pixels, 48, 10, 16); assert!(r > 200 && g < 30 && b < 30); // Right rect → green. let (r, g, b, _) = pixel(&pixels, 48, 36, 16); assert!(r < 30 && g > 200 && b < 30); // Gap between rects → clear (black). let (r, g, b, _) = pixel(&pixels, 48, 24, 16); assert!(r < 30 && g < 30 && b < 30); } #[test] fn vertex_buffer_grows_when_command_count_exceeds_capacity() { let Some(harness) = make_headless(32, 32) else { return; }; let mut pass = UiOverlayPass::with_atlas_size( harness.gpu.device(), wgpu::TextureFormat::Rgba8Unorm, 64, 64, ); // Default vertex capacity is 4096; one quad uses 6 vertices, so // 1000 quads = 6000 vertices, triggering one growth. let commands: Vec<_> = (0..1000) .map(|i| DrawCommand::Quad { rect: Rect::from_min_size( MVec2::new((i % 32) as f32, (i / 32) as f32), MVec2::new(1.0, 1.0), ), color: Color::WHITE, }) .collect(); let frame = PaintedFrame { size: MVec2::new(32.0, 32.0), commands, }; pass.set_batches(vec![UiBatch::screen_space(frame, (32, 32))]); // The run should not panic on the buffer regrow. harness.run_pass(&mut pass, Color::BLACK); } /// World-space UI panel rendered through a 3D camera. Places a red /// panel at the origin facing the camera, renders, and asserts that /// the centre of the framebuffer is red while the corners stay clear. /// This is the piece-4b gate: the `UiBatch::world_space` MVP path /// produces pixels at the right place under a real perspective /// projection. #[test] fn world_space_panel_renders_inside_its_projected_region() { use crate::math::{Transform, Vec3}; use crate::render::Camera; use crate::ui::paint::{DrawCommand, PaintedFrame}; let Some(harness) = make_headless(64, 64) else { return; }; let mut pass = UiOverlayPass::with_atlas_size( harness.gpu.device(), wgpu::TextureFormat::Rgba8Unorm, 64, 64, ); // A 2 m × 2 m panel filled with red, laid out at 32×32 pixels. let pixel_size = MVec2::new(32.0, 32.0); let world_size = MVec2::new(2.0, 2.0); let painted = PaintedFrame { size: pixel_size, commands: vec![DrawCommand::Quad { rect: Rect::from_min_size(MVec2::ZERO, pixel_size), color: Color::RED, }], }; // Panel sits at the origin with default rotation (its normal // points along +Z in panel-local space, which is +Z in world). let panel_transform = Transform::default(); // Camera at (0, 0, 3) looking at the origin: it sees the panel's // front face. With a 60° FOV and 1:1 aspect the visible width at // distance 3 is ~3.46 m, so a 2×2 m panel covers about 58% of // the view's centre — corners stay outside. let camera = Camera::perspective(60_f32.to_radians(), 0.1, 100.0); let view_transform = Transform::looking_at(Vec3::new(0.0, 0.0, 3.0), Vec3::ZERO, Vec3::Y); let view_projection = camera.view_projection(1.0, &view_transform); pass.set_batches(vec![UiBatch::world_space( painted, pixel_size, world_size, &panel_transform, view_projection, )]); harness.run_pass(&mut pass, Color::BLACK); let pixels = harness.read_pixels(); // Centre of the framebuffer → red panel. let (r, g, b, _) = pixel(&pixels, 64, 32, 32); assert!( r > 200 && g < 30 && b < 30, "centre should be red, got ({r}, {g}, {b})" ); // Corner of the framebuffer → black (panel doesn't reach there). let (r, g, b, _) = pixel(&pixels, 64, 1, 1); assert!( r < 30 && g < 30 && b < 30, "corner should be clear-black, got ({r}, {g}, {b})" ); } /// End-to-end glyph rendering on the GPU: load a system font, build a /// painted frame with a single white glyph drawn over a black /// background, render through the pass, read back pixels, and assert /// that the glyph's region contains at least one near-white pixel and /// that the corners stay black. This is the test that proves the path /// from `DrawCommand::Glyph` through atlas → vertex buffer → shader /// fragment is intact on the actual GPU (the solid-quad tests cover /// only the `uv.x < 0.0` fast path). #[test] fn glyph_command_renders_visible_pixels_in_its_region() { use crate::ui::text::{common_system_font_paths, Font, GlyphKey}; let Some(harness) = make_headless(64, 64) else { return; }; // Load a system font (skip if none available — same pattern as the // text-shaping tests). let font = (|| { for path in common_system_font_paths() { if std::path::Path::new(path).exists() { if let Ok(font) = Font::from_path(path) { return Some(font); } } } None })(); let Some(font) = font else { eprintln!("SKIP: no system font available for GPU glyph test"); return; }; let mut pass = UiOverlayPass::with_atlas_size( harness.gpu.device(), wgpu::TextureFormat::Rgba8Unorm, 128, 128, ); // Register the font with the pass so the atlas can rasterize it. let font_id = pass.fonts_mut().insert(font); // Capital 'H' at 32px — a tall, mostly-solid glyph that's easy to // hit-test in the centre of a 64×64 target. let glyph = pass.fonts().get(font_id).unwrap().glyph_id('H'); let key = GlyphKey::new(font_id, glyph, 32.0); let frame = PaintedFrame { size: MVec2::new(64.0, 64.0), commands: vec![DrawCommand::Glyph { key, // Pen position at (16, 48): baseline near the vertical // middle, so the glyph occupies roughly the central rect. pen_position: MVec2::new(16.0, 48.0), color: Color::WHITE, }], }; pass.set_batches(vec![UiBatch::screen_space(frame, (64, 64))]); harness.run_pass(&mut pass, Color::BLACK); let pixels = harness.read_pixels(); // Scan a 32×32 window around the glyph centre for any near-white // pixel. We don't assert a specific pixel because exact glyph // bitmap layout varies per font face; we only assert *something* // got drawn there. let mut found_lit = false; for y in 18..50 { for x in 16..48 { let (r, g, b, _) = pixel(&pixels, 64, x, y); if r > 200 && g > 200 && b > 200 { found_lit = true; } } } assert!( found_lit, "expected at least one near-white pixel inside the glyph's region" ); // Corner pixel must still be the clear color (black) — the glyph // is bounded, not splatted across the whole target. let (r, g, b, _) = pixel(&pixels, 64, 0, 0); assert!( r < 30 && g < 30 && b < 30, "corner should remain clear-black, got ({r}, {g}, {b})" ); } /// The atlas grows once per distinct glyph, then stops — the property the /// `ui_hud` example relies on to claim animated HUD digits become 100% /// cache hits. Renders the digits `0`–`9` one at a time (the atlas grows /// each frame), then re-renders an already-seen digit (no growth, no /// dirty flag). #[test] fn atlas_caches_glyphs_and_reaches_steady_state() { use crate::ui::text::{common_system_font_paths, Font, GlyphKey}; let Some(harness) = make_headless(32, 32) else { return; }; let font = (|| { for path in common_system_font_paths() { if std::path::Path::new(path).exists() { if let Ok(font) = Font::from_path(path) { return Some(font); } } } None })(); let Some(font) = font else { eprintln!("SKIP: no system font available for atlas-cache test"); return; }; let mut pass = UiOverlayPass::with_atlas_size( harness.gpu.device(), wgpu::TextureFormat::Rgba8Unorm, 128, 128, ); let font_id = pass.fonts_mut().insert(font); let glyph_key = |pass: &UiOverlayPass, c: char| { let glyph = pass.fonts().get(font_id).unwrap().glyph_id(c); GlyphKey::new(font_id, glyph, 24.0) }; let draw = |key: GlyphKey| { UiBatch::screen_space( PaintedFrame { size: MVec2::new(32.0, 32.0), commands: vec![DrawCommand::Glyph { key, pen_position: MVec2::new(8.0, 24.0), color: Color::WHITE, }], }, (32, 32), ) }; assert_eq!(pass.atlas_glyph_count(), 0, "atlas starts empty"); // Each distinct digit grows the atlas by exactly one entry. for (i, c) in "0123456789".chars().enumerate() { let key = glyph_key(&pass, c); pass.set_batches(vec![draw(key)]); harness.run_pass(&mut pass, Color::BLACK); assert_eq!( pass.atlas_glyph_count(), i + 1, "atlas should hold {} glyphs after digit '{c}'", i + 1 ); // `run` clears the dirty flag after uploading, so a host always // observes it false post-run. assert!(!pass.atlas_dirty(), "dirty flag is cleared after upload"); } // Re-rendering an already-cached digit is a pure cache hit: the count // holds and nothing is re-rasterized or marked dirty. let key = glyph_key(&pass, '7'); pass.set_batches(vec![draw(key)]); harness.run_pass(&mut pass, Color::BLACK); assert_eq!( pass.atlas_glyph_count(), 10, "re-drawing a cached glyph must not grow the atlas" ); assert!(!pass.atlas_dirty(), "cache hit leaves the atlas clean"); } }