Files
Oxide/engine/src/render/ui_pass.rs
T
Homer Simpson f56a1eea3b Import Oxide engine (Stages 0–10) under MIT license
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>
2026-07-05 20:41:02 +02:00

1118 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! [`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");
}
}