f56a1eea3b
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1118 lines
42 KiB
Rust
1118 lines
42 KiB
Rust
//! [`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<UiBatch>,
|
||
}
|
||
|
||
#[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::<UiVertex>() 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::<UiUniform>() 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::<UiUniform>() 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::<UiVertex>() 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<UiBatch>) {
|
||
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<UiVertex> {
|
||
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::<UiVertex>() 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<UiVertex>,
|
||
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<HeadlessHarness> {
|
||
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<u8> {
|
||
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");
|
||
}
|
||
}
|