unlit_wgpu/ui.rs
1//! An [egui](https://egui.rs) backend built on the built-in lit pipeline.
2//!
3//! [`EguiIntegration`] takes what egui produces each frame — tessellated, clipped
4//! meshes plus a set of texture updates — and draws it with the built-in
5//! pipeline as ordinary screen-space geometry. Nothing in the shader knows
6//! about egui: the UI is premultiplied, textured, screen-space vertices, and
7//! egui's clip rectangles become scissor rectangles.
8//!
9//! ```rust
10//! # use unlit_wgpu::globals::Globals;
11//! # use unlit_wgpu::pipeline::{CAMERA_BINDING, FRAME_BINDING, VIEW_BINDING};
12//! # use unlit_wgpu::resources::ResourceGraph;
13//! # use unlit_wgpu::specialize::{SpecializedPipeline, SurfaceKey};
14//! # use unlit_wgpu::ui::{
15//! # EguiIntegration, ScreenDescriptor, screen_view, screen_viewport, ui_variant,
16//! # };
17//! # use zerocopy::IntoBytes;
18//! # fn frame(device: &wgpu::Device, queue: &wgpu::Queue, ctx: &egui::Context,
19//! # surface: SurfaceKey) {
20//! // The caller owns the globals: a camera uniform (written every frame with
21//! // `screen_view`), a frame-globals uniform, the view `screen_viewport`
22//! // derives from the same points, and the bind group binding all three.
23//! let variant = ui_variant(/* the target encodes sRGB: */ true, surface);
24//! let pipeline = SpecializedPipeline::create(device, variant);
25//! let camera = uniform_buffer(device, "ui::camera");
26//! let globals = uniform_buffer(device, "ui::globals");
27//! let view = uniform_buffer(device, "ui::view");
28//! let global_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
29//! label: Some("ui::globals"),
30//! layout: &pipeline.descriptor().bind_group_layouts(device).global,
31//! entries: &[
32//! wgpu::BindGroupEntry {
33//! binding: CAMERA_BINDING,
34//! resource: camera.as_entire_binding(),
35//! },
36//! wgpu::BindGroupEntry {
37//! binding: FRAME_BINDING,
38//! resource: globals.as_entire_binding(),
39//! },
40//! wgpu::BindGroupEntry {
41//! binding: VIEW_BINDING,
42//! resource: view.as_entire_binding(),
43//! },
44//! ],
45//! });
46//!
47//! // The camera and the view are written every frame; the UI reads no clock,
48//! // so its frame globals stay at their default.
49//! queue.write_buffer(&camera, 0, screen_view([256.0, 192.0]).as_bytes());
50//! queue.write_buffer(&view, 0, screen_viewport([256.0, 192.0]).as_bytes());
51//! queue.write_buffer(&globals, 0, Globals::default().as_bytes());
52//!
53//! // The UI's per-texture resources join the caller's ledger.
54//! let mut graph = ResourceGraph::new();
55//! let camera_id = graph.insert(camera, None);
56//! let globals_id = graph.insert(globals, None);
57//! let view_id = graph.insert(view, None);
58//! let group_id = graph.insert(global_group, None);
59//! graph.add_dependency(&group_id, &camera_id);
60//! graph.add_dependency(&group_id, &globals_id);
61//! graph.add_dependency(&group_id, &view_id);
62//!
63//! let mut ui = EguiIntegration::new(device, pipeline);
64//! let mut input = egui::RawInput::default();
65//! input.screen_rect = Some(egui::Rect::from_min_size(
66//! egui::Pos2::ZERO,
67//! egui::Vec2::new(256.0, 192.0),
68//! ));
69//! let output = ctx.run_ui(input, |ui| {
70//! ui.label("hello world");
71//! });
72//! // The frame's encoder carries both the UI's staged vertex and index
73//! // uploads and the pass that reads them; `scene` is recorded into it
74//! // after this call.
75//! let mut encoder = device.create_command_encoder(&Default::default());
76//! // The target's physical size and its density, which the clip rectangles
77//! // are scaled by.
78//! let screen = ScreenDescriptor {
79//! size_in_pixels: [256, 192],
80//! pixels_per_point: 1.0,
81//! };
82//! ui.update(&mut graph, queue, &mut encoder, ctx, output, screen);
83//! let scene = ui.scene(&graph, &graph.get(&group_id).expect("the group exists"));
84//! # }
85//! # fn uniform_buffer(device: &wgpu::Device, label: &str) -> wgpu::Buffer {
86//! # device.create_buffer(&wgpu::BufferDescriptor {
87//! # label: Some(label),
88//! # size: 256,
89//! # usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
90//! # mapped_at_creation: false,
91//! # })
92//! # }
93//! ```
94
95use crate::globals::{Camera, View};
96use crate::mesh::{ChannelEncoding, MeshInstance, PositionStreamChannels, SecondaryFlags};
97use crate::pipeline::{
98 BASE_COLOR_SAMPLER_BINDING, BASE_COLOR_TEXTURE_BINDING, GLOBAL_GROUP, INSTANCE_SLOT,
99 LitOptions, LitVariant, LitVertexChannels, MATERIAL_GROUP, PRIMARY_SLOT, SECONDARY_SLOT,
100 SpecializedLitPipeline,
101};
102use crate::resources::{Rebuild, ResHandle, Resource, ResourceGraph, TextureExt, TextureView};
103use crate::scene::{DrawEntry, DrawOrder, DrawRange, Scene, ScissorRect};
104use crate::specialize::SurfaceKey;
105use crate::staging::StagingBuffer;
106use crate::util::Hashed;
107use core::ops::Range;
108use hashbrown::HashMap;
109use zerocopy::IntoBytes;
110
111/// egui's own texture format: gamma-space RGBA, never sRGB-aware.
112///
113/// egui's vertex colors and its font atlas are already gamma-encoded, so a
114/// texture is sampled without conversion and the pipeline's own output
115/// conversion deals with the target instead.
116const TEXTURE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
117
118/// Bytes one planar-position vertex occupies.
119///
120/// The UI writes positions at full precision and in the plane, so this is
121/// [`ChannelEncoding::PlanarPosition`]'s format: two components, with the
122/// zero third supplied by the shader rather than stored.
123const POSITION_STRIDE: usize = wgpu::VertexFormat::Float32x2.size() as usize;
124
125/// Bytes one secondary vertex occupies: `Float32x2` then `Unorm8x4`.
126const SECONDARY_STRIDE: usize =
127 wgpu::VertexFormat::Float32x2.size() as usize + wgpu::VertexFormat::Unorm8x4.size() as usize;
128
129/// The target a UI frame is drawn into and the density it is drawn at.
130///
131/// The two are needed together and in opposite units, which is the easiest
132/// thing to get wrong here: egui tessellates in **logical points**, so the
133/// projection takes the point size, while a scissor rectangle is in **physical
134/// pixels**, so the clip rectangles are scaled by [`Self::pixels_per_point`].
135#[derive(Clone, Copy, Debug, PartialEq)]
136pub struct ScreenDescriptor {
137 /// The target's size in physical pixels.
138 pub size_in_pixels: [u32; 2],
139 /// How many physical pixels one logical point covers.
140 pub pixels_per_point: f32,
141}
142
143impl ScreenDescriptor {
144 /// The target's size in logical points, which is what the UI's projection
145 /// and its `RawInput::screen_rect` are expressed in.
146 pub fn size_in_points(self) -> [f32; 2] {
147 [
148 self.size_in_pixels[0] as f32 / self.pixels_per_point,
149 self.size_in_pixels[1] as f32 / self.pixels_per_point,
150 ]
151 }
152}
153
154/// A world-to-clip matrix taking egui's tessellated points to clip space.
155///
156/// egui positions its vertices in logical points with the origin at the top
157/// left and Y growing downwards, so the Y coefficient is negated; Z collapses
158/// to zero, since everything the UI draws sits at one depth. The physical
159/// density never enters: the target's format already encodes its pixels.
160///
161/// The caller writes this into their camera uniform, since the camera buffer
162/// is theirs; [`screen_viewport`] is the view to bind beside it.
163pub fn screen_view(viewport_points: [f32; 2]) -> Camera {
164 let [width, height] = viewport_points;
165 Camera::from_clip_from_world(
166 glam::Mat4::from_cols_array(&[
167 2.0 / width,
168 0.0,
169 0.0,
170 0.0,
171 0.0,
172 -2.0 / height,
173 0.0,
174 0.0,
175 0.0,
176 0.0,
177 0.0,
178 0.0,
179 -1.0,
180 1.0,
181 0.0,
182 1.0,
183 ]),
184 glam::Vec3::ZERO,
185 )
186}
187
188/// The view to bind beside [`screen_view`].
189///
190/// The clipping is in points, so the size a shader measures against is the point
191/// size too: that is what keeps the two in one unit.
192pub fn screen_viewport(viewport_points: [f32; 2]) -> View {
193 View::new(viewport_points[0], viewport_points[1])
194}
195
196/// The pipeline variant a UI is drawn with.
197///
198/// `srgb_to_linear_output` is the caller's call, not this function's: it
199/// depends on the target's format, which this function never sees. Set it
200/// when the target encodes sRGB.
201///
202/// Every UI draw has the same shape: full-precision screen-space vertices
203/// carrying a premultiplied color and a texture coordinate, no skinning and no
204/// morph target, and a base-color texture sampled through a material group.
205/// What changes with the target is the surface the variant carries, so this is
206/// the only place a UI variant is built.
207pub fn ui_variant(srgb_to_linear_output: bool, surface: SurfaceKey) -> LitVariant {
208 let mut options = LitOptions::standard();
209 apply_ui_settings(&mut options, srgb_to_linear_output);
210 LitVariant {
211 options: Hashed::new(options),
212 surface,
213 channels: ui_channels(),
214 base_color_texture: true,
215 morph: false,
216 strip_index_format: None,
217 }
218}
219
220/// The per-vertex channels a UI mesh is packed with.
221///
222/// The UI writes positions at full precision and in the plane — two components,
223/// with the shader supplying the zero third — and needs no skinning, and every
224/// vertex carries a texture coordinate and a premultiplied color. A caller
225/// packing a UI mesh builds its streams to match this.
226pub fn ui_channels() -> LitVertexChannels {
227 LitVertexChannels {
228 position: PositionStreamChannels {
229 position: Some(ChannelEncoding::PlanarPosition),
230 joints: false,
231 },
232 secondary: SecondaryFlags::UV | SecondaryFlags::UNCOMPRESSED_UV | SecondaryFlags::COLOR,
233 }
234}
235
236/// Apply the UI's device-independent policy — the output conversion, culling,
237/// blending and the overlaid depth behavior — to a standard options set.
238///
239/// The geometry the UI draws with is not policy and lives in
240/// [`ui_channels`] and the variant: an encoding, a skin or a morph target is
241/// a property of the mesh, not of what the caller picked.
242fn apply_ui_settings(options: &mut LitOptions, srgb_to_linear_output: bool) {
243 options.srgb_to_linear_output = srgb_to_linear_output;
244 // A UI fragment composites what egui tessellated and shades nothing: the
245 // overlay is drawn after the scene, so a light would tint text.
246 options.unlit = true;
247 // No culling: egui does not guarantee a consistent winding order across
248 // the primitives it emits, so neither face can be discarded safely.
249 // (`LitOptions::standard` culls back faces for closed meshes; the UI is
250 // flat, overlay geometry with no inside to hide.)
251 options.primitive.cull_mode = None;
252 // egui tessellates premultiplied colors, so the source's RGB is added to
253 // the destination scaled by the alpha it leaves behind.
254 options.blend = Some(wgpu::BlendState {
255 color: wgpu::BlendComponent {
256 src_factor: wgpu::BlendFactor::One,
257 dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
258 operation: wgpu::BlendOperation::Add,
259 },
260 alpha: wgpu::BlendComponent {
261 src_factor: wgpu::BlendFactor::OneMinusDstAlpha,
262 dst_factor: wgpu::BlendFactor::One,
263 operation: wgpu::BlendOperation::Add,
264 },
265 });
266 // The UI overlays whatever the pass holds, so it neither tests nor writes
267 // depth. Whether the target has a depth attachment at all is decided by
268 // the surface the variant carries; the policy only says what a target
269 // with one would run.
270 options.depth_stencil.depth_write_enabled = false;
271 options.depth_stencil.depth_compare = wgpu::CompareFunction::Always;
272}
273
274/// How much geometry a frame holds: the two numbers the packing and the draw
275/// ranges both need, and which must agree between them.
276#[derive(Clone, Copy, Debug, PartialEq, Eq)]
277struct GeometryCounts {
278 vertices: usize,
279 indices: usize,
280}
281
282/// Count the vertices and indices every tessellated primitive in
283/// `primitives` contributes.
284fn measure<'a>(
285 primitives: impl IntoIterator<Item = (Source, &'a egui::ClippedPrimitive)>,
286) -> GeometryCounts {
287 let mut counts = GeometryCounts {
288 vertices: 0,
289 indices: 0,
290 };
291 for (_, primitive) in primitives {
292 if let Some(mesh) = mesh_of(primitive) {
293 counts.vertices += mesh.vertices.len();
294 counts.indices += mesh.indices.len();
295 }
296 }
297 counts
298}
299
300/// Pack `primitives` into `vertices` and `indices`, and fill `draws` with where
301/// each one landed.
302///
303/// Both byte buffers are cleared and resized rather than reallocated, so a
304/// caller that keeps them across frames reuses the allocation. The vertex
305/// buffer holds the primary stream first and the interleaved secondary
306/// stream second: wgpu binds one stride per slot and the two differ.
307///
308/// `counts` must be what [`measure`] returned for `primitives`: it is both how
309/// much is written and where the two vertex streams meet.
310fn pack_geometry<'a>(
311 primitives: impl IntoIterator<Item = (Source, &'a egui::ClippedPrimitive)>,
312 screen: ScreenDescriptor,
313 texture_options: &HashMap<TextureKey, egui::TextureOptions>,
314 counts: GeometryCounts,
315 vertices: &mut Vec<u8>,
316 indices: &mut Vec<u8>,
317 draws: &mut Vec<UiDraw>,
318) {
319 // The split between the two streams is the frame's own vertex count, which
320 // is what `scene` reads back. It is *not* the buffer's capacity: that is
321 // usually larger, and splitting on it would write the UVs and colors
322 // somewhere the draws do not look for them.
323 let secondary_start = counts.vertices * POSITION_STRIDE;
324 let vertex_count = counts.vertices;
325 let index_count = counts.indices;
326
327 vertices.clear();
328 vertices.resize(secondary_start + vertex_count * SECONDARY_STRIDE, 0);
329 indices.clear();
330 indices.resize(index_count * size_of::<u32>(), 0);
331 draws.clear();
332
333 let mut vertex_cursor = 0usize;
334 let mut index_cursor = 0usize;
335 for (source, primitive) in primitives {
336 // A paint callback draws its own way; this renderer handles tessellated
337 // meshes only.
338 let Some(mesh) = mesh_of(primitive) else {
339 continue;
340 };
341 if mesh.vertices.is_empty() || mesh.indices.is_empty() {
342 continue;
343 }
344
345 let first_vertex = vertex_cursor as u32;
346 let first_index = index_cursor as u32;
347
348 for vertex in &mesh.vertices {
349 let position = vertex_cursor * POSITION_STRIDE;
350 // The UI is flat: two components, the shader supplies the zero
351 // third.
352 vertices[position..position + POSITION_STRIDE]
353 .copy_from_slice([vertex.pos.x, vertex.pos.y].as_bytes());
354 let secondary = secondary_start + vertex_cursor * SECONDARY_STRIDE;
355 vertices[secondary..secondary + size_of::<egui::Vec2>()]
356 .copy_from_slice([vertex.uv.x, vertex.uv.y].as_bytes());
357 // egui's colors are premultiplied already, straight to Unorm8.
358 vertices[secondary + size_of::<egui::Vec2>()..secondary + SECONDARY_STRIDE]
359 .copy_from_slice(&vertex.color.to_array());
360 vertex_cursor += 1;
361 }
362
363 let index_start = index_cursor * size_of::<u32>();
364 for (slot, &index) in indices[index_start..]
365 .as_chunks_mut::<{ size_of::<u32>() }>()
366 .0
367 .iter_mut()
368 .zip(&mesh.indices)
369 {
370 // The primitive's indices are local to its own vertices, and the
371 // draw addresses both streams by a pool-global ordinal, so the
372 // primitive's first vertex is added here. Baking it into the
373 // indices rather than passing it as the draw's `base_vertex` costs
374 // nothing — the indices are rewritten every frame either way — and
375 // it is the only form a device without `base_vertex` (WebGL2)
376 // accepts.
377 slot.copy_from_slice((index + first_vertex).as_bytes());
378 }
379 index_cursor += mesh.indices.len();
380
381 let texture = TextureKey {
382 source,
383 id: mesh.texture_id,
384 };
385 draws.push(UiDraw {
386 indices: first_index..index_cursor as u32,
387 texture,
388 // The texture's own sampling options, not a default: a texture egui
389 // asked to sample nearest or to tile would otherwise be sampled
390 // linearly and clamped, and the sampler that matches `options` is
391 // the one the material binds.
392 options: texture_options.get(&texture).copied().unwrap_or_default(),
393 scissor: scissor_rect(primitive.clip_rect, screen),
394 });
395 }
396}
397
398/// One uploaded primitive: where its indices sit, what it samples and how it
399/// is clipped.
400struct UiDraw {
401 /// Index range into the index buffer, counting indices.
402 ///
403 /// The indices themselves already name the primitive's vertices in the
404 /// pool, so a draw needs no `base_vertex` to address them; see
405 /// [`pack_geometry`].
406 indices: Range<u32>,
407 /// The texture this primitive samples, and the context it came from.
408 texture: TextureKey,
409 options: egui::TextureOptions,
410 scissor: ScissorRect,
411}
412
413/// The graph nodes backing one egui texture slot.
414///
415/// A slot needs both: the texture, because egui patches its own atlases with
416/// partial updates and a patch is written into the texture, and the view,
417/// because a material bind group samples the view. They are two nodes with an
418/// edge between them, so a patched texture and everything built from it stay
419/// the same resources.
420#[derive(Clone)]
421struct TextureSlot {
422 /// The texture egui's deltas are written into.
423 texture: ResHandle<wgpu::Texture>,
424 /// The default view over it, which the material samples.
425 view: ResHandle<TextureView>,
426}
427
428/// Draws tessellated egui output with the built-in lit pipeline.
429///
430/// The UI's GPU resources are registered in a [`ResourceGraph`] the caller
431/// supplies — normally the one kept alongside the
432/// [`RenderAttachments`](crate::render_attachments::RenderAttachments) — so
433/// the whole frame shares one ledger. Every method that touches the graph
434/// takes it as an argument: the renderer holds only the bookkeeping (ids,
435/// keys) that maps egui's world onto graph nodes.
436pub struct EguiIntegration {
437 /// Kept to build the resources a frame turns out to need.
438 device: wgpu::Device,
439 pipeline: SpecializedLitPipeline,
440 /// Graph nodes of every allocated texture slot, keyed by origin and id.
441 textures: HashMap<TextureKey, TextureSlot>,
442 /// The sampling options egui last stated for each texture.
443 ///
444 /// Kept separately from the texture itself because every [`ImageDelta`]
445 /// carries the options that apply to the whole texture, and the draw that
446 /// samples it has to name the matching sampler:
447 /// [`Self::sampler`] keys its cache on exactly these options, so a texture
448 /// uploaded as nearest samples nearest.
449 ///
450 /// [`ImageDelta`]: egui::epaint::ImageDelta
451 texture_options: HashMap<TextureKey, egui::TextureOptions>,
452 /// One sampler per distinct set of egui sampling options seen, with its
453 /// graph node.
454 samplers: Vec<(egui::TextureOptions, ResHandle<wgpu::Sampler>)>,
455 /// One material bind group per (texture, options) pair, with its graph
456 /// node; the node depends on the texture's view and the sampler.
457 materials: Vec<(MaterialKey, ResHandle<wgpu::BindGroup>)>,
458 /// Positions, then interleaved UVs and colors.
459 vertices: Option<wgpu::Buffer>,
460 /// `Uint32` indices.
461 indices: Option<wgpu::Buffer>,
462 /// Graph node of the vertex buffer, replaced in place when it grows.
463 vertex_node: Option<ResHandle<wgpu::Buffer>>,
464 /// Graph node of the index buffer, replaced in place when it grows.
465 index_node: Option<ResHandle<wgpu::Buffer>>,
466 /// One default [`MeshInstance`], bound at [`INSTANCE_SLOT`] by every
467 /// draw.
468 ///
469 /// The built-in pipeline always declares the instance stream, and a UI
470 /// draw reads no instance state, so one identity record serves every draw.
471 /// It never changes, so it needs no graph node.
472 instance: wgpu::Buffer,
473 /// Vertices the vertex buffer holds room for.
474 vertex_capacity: usize,
475 /// Indices the index buffer holds room for.
476 index_capacity: usize,
477 /// Vertices the most recently uploaded frame holds. This is where its two
478 /// vertex streams meet, so [`Self::scene`] has to split at the same point
479 /// [`Self::upload_geometry`] packed at — which is not the capacity whenever
480 /// the buffer has room to spare.
481 frame_vertices: usize,
482 /// Staging buffer for the per-frame vertex uploads, reused across frames.
483 vertex_staging: StagingBuffer,
484 /// Staging buffer for the per-frame index uploads, reused across frames.
485 index_staging: StagingBuffer,
486 /// Layout of the frame most recently uploaded.
487 draws: Vec<UiDraw>,
488 /// Packing scratch for the vertex stream — positions then interleaved UVs
489 /// and colors — kept so a steady UI reuses one allocation instead of taking
490 /// a fresh one every frame.
491 packed_vertices: Vec<u8>,
492 /// Packing scratch for the index stream, reused the same way.
493 packed_indices: Vec<u8>,
494}
495
496/// Which context a texture belongs to.
497///
498/// Every egui context allocates its texture ids from zero, so one id names
499/// different images in different contexts. A UI drawn from several contexts —
500/// the world's own panels plus a frame laid out in another world — therefore
501/// keys its textures by this pair rather than by the id alone, which is what
502/// keeps two contexts' atlases apart without rewriting the ids a frame
503/// carries.
504///
505/// The origin of the world's own panels is [`Source::LOCAL`]; every other
506/// publisher picks its own, and the numbers are that publisher's to allocate.
507#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
508pub struct Source(pub u32);
509
510impl Source {
511 /// The context of the world doing the drawing.
512 pub const LOCAL: Self = Self(0);
513
514 /// The context of the world publishing into `slot`.
515 ///
516 /// The number is the slot's plus one, so no publisher collides with the
517 /// drawing world's own panels and each slot has a namespace of its own.
518 #[must_use]
519 pub const fn published(slot: usize) -> Self {
520 Self(slot as u32 + 1)
521 }
522}
523
524/// One texture of one source.
525#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
526struct TextureKey {
527 source: Source,
528 id: egui::TextureId,
529}
530
531/// One source's contribution to a frame: what to upload and what it owns.
532///
533/// The textures are consumed — a delta is applied once, and egui panics when
534/// one is dropped unapplied — while the primitives are borrowed: a frame laid
535/// out in another world is drawn again on every drawn frame until that world
536/// publishes a newer one, so its tessellation has to outlive the upload.
537pub struct UiFrame<'a> {
538 /// Which context this contribution came from.
539 pub source: Source,
540 /// The context's texture updates for this frame.
541 pub textures: egui::TexturesDelta,
542 /// The context's tessellated geometry.
543 pub primitives: &'a [egui::ClippedPrimitive],
544}
545
546/// What one material bind group was built from.
547#[derive(Clone, Copy, PartialEq, Eq)]
548struct MaterialKey {
549 texture: TextureKey,
550 options: egui::TextureOptions,
551}
552
553/// A one-element buffer holding the identity [`MeshInstance`].
554///
555/// The built-in lit pipeline declares the instance stream for every variant,
556/// so a draw that binds nothing at [`INSTANCE_SLOT`] is rejected. A UI draw
557/// reads no instance state — its vertices are already in screen space — so one
558/// default record satisfies the slot for every draw the integration records.
559fn default_instance(device: &wgpu::Device) -> wgpu::Buffer {
560 let instance = MeshInstance::default();
561 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
562 label: Some("ui::instance"),
563 size: size_of::<MeshInstance>() as u64,
564 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
565 mapped_at_creation: true,
566 });
567 buffer
568 .slice(..)
569 .get_mapped_range_mut()
570 .expect("mapped at creation")
571 .copy_from_slice(instance.as_bytes());
572 buffer.unmap();
573 buffer
574}
575
576impl EguiIntegration {
577 /// Build the UI integration around a caller-supplied `pipeline`,
578 /// registering the UI's per-texture resources in `graph`.
579 ///
580 /// `pipeline` is the built-in lit pipeline the UI draws with, configured
581 /// for the target the pass renders into — its color format, sample count
582 /// and blend state. The caller builds it up front, so the UI does not
583 /// compile a pipeline of its own.
584 ///
585 /// The global bind group — the camera, the frame globals and the view —
586 /// is the caller's, and it is handed to [`Self::scene`] rather than kept
587 /// here: the frame owns the uniforms and builds one group per camera and
588 /// viewport its draws name, so a source can draw the same UI through two
589 /// cameras without owning either group. Everything else the UI overlays is
590 /// the caller's too: the integration owns only egui's textures and the
591 /// geometry they are drawn with. Sharing the caller's graph — usually the
592 /// one kept alongside the
593 /// [`crate::render_attachments::RenderAttachments`] — means those
594 /// resources live in the same ledger as the rest of the frame's, with the
595 /// same dependency tracking.
596 pub fn new(device: &wgpu::Device, pipeline: SpecializedLitPipeline) -> Self {
597 Self {
598 device: device.clone(),
599 pipeline,
600 textures: HashMap::new(),
601 texture_options: HashMap::new(),
602 samplers: Vec::new(),
603 materials: Vec::new(),
604 vertices: None,
605 indices: None,
606 vertex_node: None,
607 index_node: None,
608 instance: default_instance(device),
609 vertex_capacity: 0,
610 index_capacity: 0,
611 frame_vertices: 0,
612 vertex_staging: StagingBuffer::new(),
613 index_staging: StagingBuffer::new(),
614 draws: Vec::new(),
615 packed_vertices: Vec::new(),
616 packed_indices: Vec::new(),
617 }
618 }
619
620 /// Apply `output`'s texture updates and upload its tessellated shapes,
621 /// ready for [`Self::scene`].
622 ///
623 /// The vertex and index bytes change every frame, so they are staged
624 /// through `encoder`: `scene`'s pass is recorded into the same encoder
625 /// afterwards, and one submission carries the upload and the draw that
626 /// reads it. Texture updates still go through `queue`.
627 ///
628 /// `screen` tells egui how to rasterise text and scales the tessellated points
629 /// and their clip rectangles; see [`ScreenDescriptor`].
630 ///
631 /// This tessellates `output` with `ctx`, which is right when the context
632 /// and the drawing share a world. A caller whose shapes were laid out
633 /// elsewhere — in another world, by another context — uses
634 /// [`Self::update_with`] and tessellates where the context lives.
635 pub fn update(
636 &mut self,
637 graph: &mut ResourceGraph,
638 queue: &wgpu::Queue,
639 encoder: &mut wgpu::CommandEncoder,
640 ctx: &egui::Context,
641 mut output: egui::FullOutput,
642 screen: ScreenDescriptor,
643 ) {
644 let primitives = ctx.tessellate(output.shapes, screen.pixels_per_point);
645 let textures = core::mem::take(&mut output.textures_delta);
646 let mut sources = [UiFrame {
647 source: Source::LOCAL,
648 textures,
649 primitives: &primitives,
650 }];
651 self.update_with(graph, queue, encoder, &mut sources, screen);
652 }
653
654 /// Apply texture updates and upload primitives from every source, ready
655 /// for [`Self::scene`].
656 ///
657 /// Tessellation needs the [`egui::Context`] that produced the shapes — its
658 /// font atlas and tessellation options — so a UI laid out in one world and
659 /// drawn in another cannot be tessellated by the drawing side. Such a
660 /// caller tessellates where each context lives and hands each result over
661 /// with the [`Source`] it came from.
662 ///
663 /// A source is a namespace, not an identity: every context allocates its
664 /// texture ids from zero, so the same id names different images in
665 /// different sources. Keys carry the pair, and the ids a frame arrived
666 /// with are the ids it is drawn by — nothing rewrites them.
667 ///
668 /// One call per frame: the integration packs one vertex and index buffer,
669 /// and the two vertex streams meet at a single split point, so a second
670 /// call would overwrite the first.
671 ///
672 /// Each `textures` delta is applied and then cleared, so the caller may
673 /// drop it afterwards: egui panics when a delta is dropped unapplied.
674 pub fn update_with(
675 &mut self,
676 graph: &mut ResourceGraph,
677 queue: &wgpu::Queue,
678 encoder: &mut wgpu::CommandEncoder,
679 sources: &mut [UiFrame<'_>],
680 screen: ScreenDescriptor,
681 ) {
682 for frame in sources.iter_mut() {
683 self.apply_textures(graph, queue, frame.source, &frame.textures);
684 // egui panics if a delta is dropped unapplied, so mark it handled.
685 frame.textures.clear();
686 }
687
688 // The primitives of every source, flattened in place: the sources
689 // keep their own tessellation and this walks it without collecting a
690 // list of borrows first.
691 let primitives = sources.iter().flat_map(|frame| {
692 frame
693 .primitives
694 .iter()
695 .map(move |primitive| (frame.source, primitive))
696 });
697
698 let counts = measure(primitives);
699 if counts.vertices == 0 {
700 self.draws.clear();
701 return;
702 }
703 self.reserve(graph, counts);
704 self.frame_vertices = counts.vertices;
705 let primitives = sources.iter().flat_map(|frame| {
706 frame
707 .primitives
708 .iter()
709 .map(move |primitive| (frame.source, primitive))
710 });
711 self.upload_geometry(encoder, primitives, screen, counts);
712
713 // Build every material the frame needs here, while the graph is still
714 // mutably available: `scene` only reads it, so a material that does
715 // not exist yet by then is one this frame cannot draw.
716 //
717 // The draws are walked by index because `build_material` needs `&mut
718 // self`: collecting the keys first would allocate a `Vec` every frame
719 // for a list that is already `self.draws`.
720 for index in 0..self.draws.len() {
721 let (texture, options) = {
722 let draw = &self.draws[index];
723 (draw.texture, draw.options)
724 };
725 self.build_material(graph, texture, options);
726 }
727 }
728
729 /// Forget every graph node the integration registered, and its bookkeeping.
730 ///
731 /// The ids this drops are the last references the graph holds to the two
732 /// geometry buffers, every texture, view, sampler and material, so the next
733 /// [`ResourceGraph::maintain`] collects them — and, once the material nodes
734 /// go, the views and textures they were the last holders of. No graph is
735 /// needed here: giving a resource up is dropping its id.
736 ///
737 /// After this the integration is back to its freshly built state and may be
738 /// reused against the same graph.
739 pub fn release(&mut self) {
740 self.textures.clear();
741 self.texture_options.clear();
742 self.materials.clear();
743 self.samplers.clear();
744 self.vertex_node = None;
745 self.index_node = None;
746 self.vertices = None;
747 self.indices = None;
748 self.instance = default_instance(&self.device);
749 self.vertex_capacity = 0;
750 self.index_capacity = 0;
751 self.frame_vertices = 0;
752 self.draws.clear();
753 }
754
755 /// Pack `primitives` into the vertex and index buffers, staging the bytes
756 /// through `encoder`, and fill [`Self::draws`] with where each one landed.
757 ///
758 /// The primary and secondary streams go into two regions of the one buffer:
759 /// wgpu binds one stride per slot, and the two streams differ.
760 fn upload_geometry<'a>(
761 &mut self,
762 encoder: &mut wgpu::CommandEncoder,
763 primitives: impl IntoIterator<Item = (Source, &'a egui::ClippedPrimitive)>,
764 screen: ScreenDescriptor,
765 counts: GeometryCounts,
766 ) {
767 // The scratch buffers are taken out of `self` for the duration, so the
768 // draw list can be filled while they are written, then put back: a
769 // steady UI reuses one allocation per stream rather than taking fresh
770 // ones every frame.
771 let mut vertices = core::mem::take(&mut self.packed_vertices);
772 let mut indices = core::mem::take(&mut self.packed_indices);
773 pack_geometry(
774 primitives,
775 screen,
776 &self.texture_options,
777 counts,
778 &mut vertices,
779 &mut indices,
780 &mut self.draws,
781 );
782
783 let device = &self.device;
784 let vertex_buffer = self.vertices.as_ref().expect("just reserved");
785 let index_buffer = self.indices.as_ref().expect("just reserved");
786 self.vertex_staging
787 .write(device, encoder, vertex_buffer, 0, &vertices);
788 self.index_staging
789 .write(device, encoder, index_buffer, 0, &indices);
790 self.packed_vertices = vertices;
791 self.packed_indices = indices;
792 }
793
794 /// The frame most recently uploaded, as a scene.
795 ///
796 /// Every draw sets a scissor rectangle, and a scissor stays set for the
797 /// rest of the pass, so record this after the draws it overlays.
798 ///
799 /// The materials the frame needs were built by [`Self::update`], so this
800 /// only reads the graph — a scene can therefore be taken while the graph is
801 /// shared with whatever else the frame is drawing.
802 ///
803 /// `global` is the caller's global bind group for the camera, the frame
804 /// globals and the view this frame draws through; the UI binds it at
805 /// [`GLOBAL_GROUP`] for every draw.
806 pub fn scene(&self, graph: &ResourceGraph, global: &wgpu::BindGroup) -> Scene {
807 if self.vertices.is_none() || self.indices.is_none() || self.draws.is_empty() {
808 return Scene::new();
809 }
810 let mut scene = Scene::new();
811
812 let (vertices, indices) = (
813 self.vertices.as_ref().expect("checked"),
814 self.indices.as_ref().expect("checked"),
815 );
816 // The position stream is the first half of the buffer, the interleaved
817 // UVs and colors the second, and the two meet at the *frame's* vertex
818 // count — the point `upload_geometry` packed at. The buffer usually has
819 // room to spare, so splitting at `vertex_capacity` instead would bind
820 // the second stream to bytes no vertex was written to.
821 let positions_size = (self.frame_vertices * POSITION_STRIDE) as u64;
822 let secondary_size = (self.frame_vertices * SECONDARY_STRIDE) as u64;
823 let pipeline = &self.pipeline.pipeline;
824 for draw in &self.draws {
825 // Each draw carries its own scissor rectangle, so consecutive
826 // draws sharing one are what the recorder's deduplication is for.
827 let Some(material) = self.material(graph, draw.texture, draw.options) else {
828 // A primitive whose texture was freed this frame has nothing
829 // to sample, so it is skipped rather than bound to a stale view.
830 continue;
831 };
832 // Both slots cover their whole stream, and the primitive's indices
833 // already name its vertices pool-globally, so the draw needs no
834 // base vertex: one range addresses the position, the UV and the
835 // color together.
836 // The trailing tier of the composite: the UI sits above the
837 // depth-sorted pool whatever its depth, and a clipped draw here
838 // is followed by nothing its rectangle could leak into.
839 let entry = DrawEntry::new(pipeline, DrawRange::indexed(draw.indices.clone()))
840 .with_order(DrawOrder::After)
841 .with_bind_group(GLOBAL_GROUP, global)
842 .with_bind_group(MATERIAL_GROUP, material)
843 .with_vertex_buffer(INSTANCE_SLOT, &self.instance)
844 .with_vertex_buffer_range(PRIMARY_SLOT, vertices, 0..positions_size)
845 .with_vertex_buffer_range(
846 SECONDARY_SLOT,
847 vertices,
848 positions_size..positions_size + secondary_size,
849 )
850 .with_index_buffer(indices, wgpu::IndexFormat::Uint32)
851 .with_scissor(draw.scissor);
852 scene.draws.push(entry);
853 }
854 scene
855 }
856
857 /// Reallocate the buffers when they cannot hold `counts`, registering any
858 /// new buffer in `graph`.
859 fn reserve(&mut self, graph: &mut ResourceGraph, counts: GeometryCounts) {
860 let GeometryCounts { vertices, indices } = counts;
861 // Grow geometrically so a UI that grows over a few frames does not
862 // reallocate every frame.
863 if self.vertices.is_none() || self.vertex_capacity < vertices {
864 self.vertex_capacity = vertices
865 .max(self.vertex_capacity + self.vertex_capacity / 2)
866 .max(1);
867 let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
868 label: Some("ui::vertices"),
869 size: (self.vertex_capacity * (POSITION_STRIDE + SECONDARY_STRIDE)) as u64,
870 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
871 mapped_at_creation: false,
872 });
873 self.vertex_node = Some(match self.vertex_node.take() {
874 // A grow replaces the buffer in its existing node, so no stale
875 // node is left strongly holding the old buffer.
876 Some(node) => {
877 graph
878 .replace(&node, buffer.clone())
879 .expect("the vertex node is still registered");
880 node
881 }
882 None => graph.insert(buffer.clone(), None),
883 });
884 self.vertices = Some(buffer);
885 }
886 if self.indices.is_none() || self.index_capacity < indices {
887 self.index_capacity = indices
888 .max(self.index_capacity + self.index_capacity / 2)
889 .max(1);
890 let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
891 label: Some("ui::indices"),
892 size: (self.index_capacity * size_of::<u32>()) as u64,
893 usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
894 mapped_at_creation: false,
895 });
896 self.index_node = Some(match self.index_node.take() {
897 Some(node) => {
898 graph
899 .replace(&node, buffer.clone())
900 .expect("the index node is still registered");
901 node
902 }
903 None => graph.insert(buffer.clone(), None),
904 });
905 self.indices = Some(buffer);
906 }
907 }
908
909 /// The material bind group for (`id`, `options`), if it exists.
910 fn material<'a>(
911 &self,
912 graph: &'a ResourceGraph,
913 texture: TextureKey,
914 options: egui::TextureOptions,
915 ) -> Option<&'a wgpu::BindGroup> {
916 let key = MaterialKey { texture, options };
917 let index = self.materials.iter().position(|(seen, _)| *seen == key)?;
918 graph.get(&self.materials[index].1)
919 }
920
921 /// Build the material bind group for (`id`, `options`) if it does not
922 /// exist, registered in the graph as a dependent of its texture's view
923 /// and its sampler.
924 ///
925 /// The group is built once here and rebuilt by
926 /// [`ResourceGraph::maintain`] whenever the view or the sampler is
927 /// replaced: the recipe reads both back out of the graph by id, so a
928 /// texture which patches itself into a new handle is picked up without
929 /// the material being rebuilt by hand.
930 fn build_material(
931 &mut self,
932 graph: &mut ResourceGraph,
933 texture: TextureKey,
934 options: egui::TextureOptions,
935 ) {
936 let key = MaterialKey { texture, options };
937 if self.materials.iter().any(|(seen, _)| *seen == key) {
938 return;
939 }
940 let Some(slot) = self.textures.get(&texture).cloned() else {
941 return;
942 };
943 let sampler_id = self.sampler(graph, options);
944 let layout = self
945 .pipeline
946 .descriptor()
947 .bind_group_layouts(&self.device)
948 .material
949 .expect("the UI variant always samples a base-color texture");
950 let device = self.device.clone();
951 let view_id = slot.view.clone();
952 let sampler_for_build = sampler_id.clone();
953 let build = move |graph: &ResourceGraph| {
954 let view = graph
955 .get(&view_id)
956 .expect("the texture view is in the graph")
957 .view()
958 .clone();
959 let sampler = graph
960 .get(&sampler_for_build)
961 .expect("the sampler is in the graph")
962 .clone();
963 device.create_bind_group(&wgpu::BindGroupDescriptor {
964 label: Some("ui::material"),
965 layout: &layout,
966 entries: &[
967 wgpu::BindGroupEntry {
968 binding: BASE_COLOR_TEXTURE_BINDING,
969 resource: wgpu::BindingResource::TextureView(&view),
970 },
971 wgpu::BindGroupEntry {
972 binding: BASE_COLOR_SAMPLER_BINDING,
973 resource: wgpu::BindingResource::Sampler(&sampler),
974 },
975 ],
976 })
977 };
978 let group = build(graph);
979 let rebuild = Rebuild::new(move |graph| Resource::BindGroup(build(graph)));
980 let id = graph.insert(group, Some(rebuild));
981 graph.add_dependency(&id, &slot.view);
982 graph.add_dependency(&id, &sampler_id);
983 self.materials.push((key, id));
984 }
985
986 /// The sampler for `options`, created and registered on first use.
987 fn sampler(
988 &mut self,
989 graph: &mut ResourceGraph,
990 options: egui::TextureOptions,
991 ) -> ResHandle<wgpu::Sampler> {
992 if let Some((_, id)) = self.samplers.iter().find(|(seen, _)| *seen == options) {
993 return id.clone();
994 }
995 let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
996 label: Some("ui::sampler"),
997 address_mode_u: address_mode(options.wrap_mode),
998 address_mode_v: address_mode(options.wrap_mode),
999 mag_filter: texture_filter(options.magnification),
1000 min_filter: texture_filter(options.minification),
1001 ..Default::default()
1002 });
1003 let id = graph.insert(sampler, None);
1004 self.samplers.push((options, id.clone()));
1005 id
1006 }
1007
1008 fn apply_textures(
1009 &mut self,
1010 graph: &mut ResourceGraph,
1011 queue: &wgpu::Queue,
1012 source: Source,
1013 delta: &egui::TexturesDelta,
1014 ) {
1015 for (&id, deltas) in &delta.set {
1016 let key = TextureKey { source, id };
1017 for image in deltas {
1018 // Every delta for a texture states its sampling options, and
1019 // the last one wins: they describe the texture as a whole, not
1020 // the patch being written.
1021 self.texture_options.insert(key, image.options);
1022 self.upload_texture(graph, queue, key, image);
1023 }
1024 }
1025 for &id in &delta.free {
1026 // Dropping the slot drops the ids to the texture and its view, so
1027 // the next [`ResourceGraph::maintain`] collects them — and the
1028 // materials that were built from the view, since the view's
1029 // reference count reaches zero with the slot's. The materials'
1030 // own entries go here, before that pass, so egui reusing the
1031 // (texture, options) pair rebuilds a material rather than finding
1032 // a stale one.
1033 //
1034 // The key carries the source: two contexts both free id zero at
1035 // some point, and the other context's texture is not this one's to
1036 // drop.
1037 let key = TextureKey { source, id };
1038 self.textures.remove(&key);
1039 self.texture_options.remove(&key);
1040 self.materials.retain(|(seen, _)| seen.texture != key);
1041 }
1042 }
1043
1044 fn upload_texture(
1045 &mut self,
1046 graph: &mut ResourceGraph,
1047 queue: &wgpu::Queue,
1048 key: TextureKey,
1049 delta: &egui::epaint::ImageDelta,
1050 ) {
1051 let [width, height] = delta.image.size();
1052 // The image's own bytes, borrowed rather than copied into a fresh
1053 // `Vec`: a font atlas can be megabytes, and this is the whole of it.
1054 let pixels: &[u8] = match &delta.image {
1055 egui::ImageData::Color(image) => image.as_raw(),
1056 };
1057 let layout = wgpu::TexelCopyBufferLayout {
1058 offset: 0,
1059 bytes_per_row: Some(width as u32 * texture_bytes()),
1060 rows_per_image: Some(height as u32),
1061 };
1062 let size = wgpu::Extent3d {
1063 width: width as u32,
1064 height: height as u32,
1065 depth_or_array_layers: 1,
1066 };
1067
1068 match delta.pos {
1069 // A patch written into the texture already there.
1070 Some([x, y]) => {
1071 let Some(slot) = self.textures.get(&key) else {
1072 return;
1073 };
1074 let Some(texture) = graph.get(&slot.texture) else {
1075 return;
1076 };
1077 queue.write_texture(
1078 wgpu::TexelCopyTextureInfo {
1079 texture,
1080 mip_level: 0,
1081 origin: wgpu::Origin3d {
1082 x: x as u32,
1083 y: y as u32,
1084 z: 0,
1085 },
1086 aspect: wgpu::TextureAspect::All,
1087 },
1088 pixels,
1089 layout,
1090 size,
1091 );
1092 }
1093 None => {
1094 let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1095 label: Some("ui::texture"),
1096 size,
1097 mip_level_count: 1,
1098 sample_count: 1,
1099 dimension: wgpu::TextureDimension::D2,
1100 format: TEXTURE_FORMAT,
1101 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1102 view_formats: &[],
1103 });
1104 queue.write_texture(texture.as_image_copy(), pixels, layout, size);
1105 let view =
1106 TextureExt::create_view(&texture, &wgpu::TextureViewDescriptor::default());
1107 let texture_id = graph.insert(texture, None);
1108 let view_id = graph.insert(view, None);
1109 graph.add_dependency(&view_id, &texture_id);
1110 self.textures.insert(
1111 key,
1112 TextureSlot {
1113 texture: texture_id,
1114 view: view_id,
1115 },
1116 );
1117 }
1118 }
1119 }
1120}
1121
1122/// Bytes per texel of [`TEXTURE_FORMAT`], derived from the format.
1123fn texture_bytes() -> u32 {
1124 TEXTURE_FORMAT
1125 .block_copy_size(None)
1126 .expect("rgba8 has a known texel size")
1127}
1128
1129fn address_mode(mode: egui::TextureWrapMode) -> wgpu::AddressMode {
1130 match mode {
1131 egui::TextureWrapMode::ClampToEdge => wgpu::AddressMode::ClampToEdge,
1132 egui::TextureWrapMode::Repeat => wgpu::AddressMode::Repeat,
1133 egui::TextureWrapMode::MirroredRepeat => wgpu::AddressMode::MirrorRepeat,
1134 }
1135}
1136
1137fn texture_filter(filter: egui::TextureFilter) -> wgpu::FilterMode {
1138 match filter {
1139 egui::TextureFilter::Nearest => wgpu::FilterMode::Nearest,
1140 egui::TextureFilter::Linear => wgpu::FilterMode::Linear,
1141 }
1142}
1143
1144/// The mesh of `primitive`, or `None` for a paint callback.
1145fn mesh_of(primitive: &egui::ClippedPrimitive) -> Option<&egui::epaint::Mesh> {
1146 match &primitive.primitive {
1147 egui::epaint::Primitive::Mesh(mesh) => Some(mesh),
1148 egui::epaint::Primitive::Callback(_) => None,
1149 }
1150}
1151
1152/// egui's clip rectangle as a scissor rectangle, in whole pixels.
1153///
1154/// egui states a clip rectangle in logical points, while a scissor rectangle is
1155/// in physical pixels, so the rectangle is scaled by the density and rounded to
1156/// whole pixels — the same conversion `egui-wgpu` performs. Both edges are then
1157/// clamped to the target: a clip rectangle may extend past it (a panel scrolled
1158/// out of view, or a widget larger than the surface), and a scissor rectangle
1159/// outside the attachment is a validation error rather than a no-op.
1160fn scissor_rect(clip_rect: egui::Rect, screen: ScreenDescriptor) -> ScissorRect {
1161 let ppp = screen.pixels_per_point;
1162 let [width, height] = screen.size_in_pixels;
1163
1164 let min_x = (ppp * clip_rect.min.x).round().clamp(0.0, width as f32) as u32;
1165 let min_y = (ppp * clip_rect.min.y).round().clamp(0.0, height as f32) as u32;
1166 let max_x = (ppp * clip_rect.max.x)
1167 .round()
1168 .clamp(min_x as f32, width as f32) as u32;
1169 let max_y = (ppp * clip_rect.max.y)
1170 .round()
1171 .clamp(min_y as f32, height as f32) as u32;
1172
1173 ScissorRect {
1174 x: min_x,
1175 y: min_y,
1176 width: max_x - min_x,
1177 height: max_y - min_y,
1178 }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183 use super::*;
1184
1185 /// The UI options for a device-free test. Only the policy these tests
1186 /// assert on matters, so the depth-stencil format is the
1187 /// device-independent placeholder.
1188 fn ui_options_for_tests(srgb_to_linear_output: bool) -> LitOptions {
1189 let mut options = LitOptions::standard();
1190 apply_ui_settings(&mut options, srgb_to_linear_output);
1191 options
1192 }
1193
1194 /// A UI variant over the device-free options, for tests that assert on
1195 /// what a variant derives rather than on a compiled pipeline.
1196 fn ui_variant_for_tests(srgb_to_linear_output: bool) -> LitVariant {
1197 LitVariant {
1198 options: Hashed::new(ui_options_for_tests(srgb_to_linear_output)),
1199 surface: SurfaceKey {
1200 color_format: wgpu::TextureFormat::Rgba8UnormSrgb,
1201 depth_stencil_format: None,
1202 sample_count: 1,
1203 },
1204 channels: ui_channels(),
1205 base_color_texture: true,
1206 morph: false,
1207 strip_index_format: None,
1208 }
1209 }
1210
1211 #[test]
1212 fn ui_variant_is_screen_space_and_uncompressed() {
1213 let variant = ui_variant_for_tests(false);
1214 let channels = variant.channels;
1215 assert_eq!(
1216 channels.position.position,
1217 Some(ChannelEncoding::PlanarPosition),
1218 "screen-space vertices are written at full precision and in the plane"
1219 );
1220 assert!(!channels.position.joints, "a UI mesh is never skinned");
1221 assert_eq!(
1222 channels.secondary,
1223 SecondaryFlags::UV | SecondaryFlags::UNCOMPRESSED_UV | SecondaryFlags::COLOR
1224 );
1225 assert!(variant.base_color_texture);
1226 // Nothing is compressed, so no draw decodes through a metadata index:
1227 // the instance stream carries no per-mesh state the UI would read.
1228 assert!(!variant.needs_metadata());
1229 assert!(!variant.needs_joints());
1230 assert!(!variant.needs_morphs());
1231 // The instance stream is declared for every unlit variant, so a UI
1232 // draw binds the default record.
1233 assert!(variant.vertex_buffer_layouts()[INSTANCE_SLOT as usize].is_some());
1234 let options = &variant.options;
1235 // Overlaid rather than depth-tested: the policy says what a target
1236 // with an attachment would run, and the surface says whether it has
1237 // one.
1238 assert!(!options.depth_stencil.depth_write_enabled);
1239 assert_eq!(
1240 options.depth_stencil.depth_compare,
1241 wgpu::CompareFunction::Always
1242 );
1243 // egui's colors premultiply their own alpha.
1244 let blend = options.blend.expect("the UI variant blends");
1245 assert_eq!(blend.color.src_factor, wgpu::BlendFactor::One);
1246 assert_eq!(blend.color.dst_factor, wgpu::BlendFactor::OneMinusSrcAlpha);
1247 }
1248
1249 /// The conversion flag follows the parameter, since it depends on the
1250 /// target's format, not the UI.
1251 #[test]
1252 fn srgb_flag_follows_the_parameter() {
1253 assert!(!ui_options_for_tests(false).srgb_to_linear_output);
1254 assert!(ui_options_for_tests(true).srgb_to_linear_output);
1255 }
1256
1257 /// The projection maps the viewport's corners onto clip space, with Y
1258 /// flipped: egui's origin is the top left, clip space's the bottom left.
1259 #[test]
1260 fn screen_view_maps_the_viewport_corners() {
1261 let camera = screen_view([256.0, 192.0]);
1262 for (point, expected) in [([0.0, 0.0], [-1.0, 1.0]), ([256.0, 192.0], [1.0, -1.0])] {
1263 let clip = camera.clip_from_world * glam::Vec4::new(point[0], point[1], 0.0, 1.0);
1264 let got = [clip.x / clip.w, clip.y / clip.w];
1265 assert!(
1266 (got[0] - expected[0]).abs() < 1e-5 && (got[1] - expected[1]).abs() < 1e-5,
1267 "point {point:?} projected to {got:?}, expected {expected:?}"
1268 );
1269 }
1270 // The viewport the same points describe is the view bound beside it.
1271 assert_eq!(screen_viewport([256.0, 192.0]), View::new(256.0, 192.0));
1272 }
1273
1274 /// A clip rectangle reaching off the target is clamped rather than wrapping
1275 /// into a huge `u32`, and its far edge never falls below its near edge.
1276 #[test]
1277 fn scissor_rect_clamps_to_the_target() {
1278 let screen = ScreenDescriptor {
1279 size_in_pixels: [64, 48],
1280 pixels_per_point: 1.0,
1281 };
1282 let rect = scissor_rect(
1283 egui::Rect::from_two_pos(egui::Pos2::new(-20.0, -8.0), egui::Pos2::new(200.0, 200.0)),
1284 screen,
1285 );
1286 assert_eq!(rect.x, 0);
1287 assert_eq!(rect.y, 0);
1288 // Both edges are clamped to the target, so a clip rectangle larger
1289 // than it covers exactly the target rather than overrunning it.
1290 assert_eq!(rect.width, 64);
1291 assert_eq!(rect.height, 48);
1292
1293 // A rectangle entirely off the target collapses to nothing instead of
1294 // producing a negative width.
1295 let off = scissor_rect(
1296 egui::Rect::from_two_pos(egui::Pos2::new(-40.0, -40.0), egui::Pos2::new(-10.0, -10.0)),
1297 screen,
1298 );
1299 assert_eq!((off.x, off.y, off.width, off.height), (0, 0, 0, 0));
1300 }
1301
1302 /// A clip rectangle is stated in logical points, so at a density above one
1303 /// it must cover that many physical pixels — the conversion egui-wgpu
1304 /// performs. Getting it wrong clips the UI short of its own bounds.
1305 #[test]
1306 fn scissor_rect_scales_points_to_physical_pixels() {
1307 let screen = ScreenDescriptor {
1308 size_in_pixels: [256, 192],
1309 pixels_per_point: 2.0,
1310 };
1311 // A 16x8-point clip at (8, 4) is 32x16 physical pixels at (16, 8).
1312 let clip = egui::Rect::from_min_size(egui::Pos2::new(8.0, 4.0), egui::Vec2::new(16.0, 8.0));
1313 let rect = scissor_rect(clip, screen);
1314 assert_eq!(
1315 rect,
1316 ScissorRect {
1317 x: 16,
1318 y: 8,
1319 width: 32,
1320 height: 16
1321 }
1322 );
1323
1324 // Where an unscaled conversion would have ended, in pixels: egui's own
1325 // point coordinates taken as pixels. The scaled rectangle extends
1326 // beyond it, so an unscaled clip would have cut that strip away.
1327 let unscaled_end = clip.max.x as u32;
1328 assert!(
1329 unscaled_end < rect.x + rect.width,
1330 "the scaled rectangle must reach past where an unscaled one would \
1331 have ended ({unscaled_end} px), but it ends at {} px",
1332 rect.x + rect.width
1333 );
1334 }
1335
1336 /// Fractional points round to whole pixels rather than truncating, so a
1337 /// clip edge does not drift by up to a pixel.
1338 #[test]
1339 fn scissor_rect_rounds_fractional_points() {
1340 let screen = ScreenDescriptor {
1341 size_in_pixels: [64, 64],
1342 pixels_per_point: 1.0,
1343 };
1344 let rect = scissor_rect(
1345 egui::Rect::from_min_size(egui::Pos2::new(1.4, 2.6), egui::Vec2::new(4.0, 4.0)),
1346 screen,
1347 );
1348 assert_eq!((rect.x, rect.y), (1, 3));
1349
1350 let rounded_up = scissor_rect(
1351 egui::Rect::from_min_size(egui::Pos2::new(1.6, 2.4), egui::Vec2::new(4.0, 4.0)),
1352 screen,
1353 );
1354 assert_eq!((rounded_up.x, rounded_up.y), (2, 2));
1355 }
1356
1357 /// The point size the projection takes is the physical size divided by the
1358 /// density — the opposite conversion from the scissor rectangle's.
1359 #[test]
1360 fn size_in_points_divides_by_the_density() {
1361 let screen = ScreenDescriptor {
1362 size_in_pixels: [256, 192],
1363 pixels_per_point: 2.0,
1364 };
1365 assert_eq!(screen.size_in_points(), [128.0, 96.0]);
1366 }
1367
1368 /// A screen descriptor matching the default test target.
1369 fn test_screen() -> ScreenDescriptor {
1370 ScreenDescriptor {
1371 size_in_pixels: [256, 192],
1372 pixels_per_point: 1.0,
1373 }
1374 }
1375
1376 /// Tag `primitives` with the local source: these tests exercise packing,
1377 /// not the namespaces a frame arrives from.
1378 fn local(primitives: &[egui::ClippedPrimitive]) -> Vec<(Source, &egui::ClippedPrimitive)> {
1379 primitives
1380 .iter()
1381 .map(|primitive| (Source::LOCAL, primitive))
1382 .collect()
1383 }
1384
1385 /// One tessellated primitive carrying `vertices` points, clipped to the
1386 /// whole screen.
1387 fn primitive(texture: egui::TextureId, vertices: usize) -> egui::ClippedPrimitive {
1388 let mut mesh = egui::epaint::Mesh::with_texture(texture);
1389 for index in 0..vertices {
1390 mesh.vertices.push(egui::epaint::Vertex {
1391 pos: egui::pos2(index as f32, index as f32 * 2.0),
1392 uv: egui::pos2(0.25, 0.75),
1393 color: egui::Color32::from_rgba_premultiplied(1, 2, 3, 4),
1394 });
1395 }
1396 // A degenerate triangle per vertex, enough to exercise the index path.
1397 for index in 0..vertices.saturating_sub(2) {
1398 mesh.indices
1399 .extend([index as u32, index as u32 + 1, index as u32 + 2]);
1400 }
1401 egui::ClippedPrimitive {
1402 clip_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(256.0, 192.0)),
1403 primitive: egui::epaint::Primitive::Mesh(mesh),
1404 }
1405 }
1406
1407 /// The packing lands each primitive's bytes where its draw says, and the
1408 /// two vertex streams hold the values egui put in.
1409 #[test]
1410 fn packing_places_each_primitive_where_its_draw_says() {
1411 let primitives = [primitive(egui::TextureId::default(), 4)];
1412 let mut vertices = Vec::new();
1413 let mut indices = Vec::new();
1414 let mut draws = Vec::new();
1415 let tagged = local(&primitives);
1416 let counts = measure(tagged.iter().copied());
1417 pack_geometry(
1418 tagged.iter().copied(),
1419 test_screen(),
1420 &HashMap::new(),
1421 counts,
1422 &mut vertices,
1423 &mut indices,
1424 &mut draws,
1425 );
1426
1427 assert_eq!(draws.len(), 1, "one mesh is one draw");
1428 let draw = &draws[0];
1429 assert_eq!(draw.indices, 0..6, "four vertices make two triangles");
1430 // This primitive starts at the pool's first vertex, so baking its
1431 // offset in leaves the indices as egui wrote them.
1432 assert_eq!(
1433 packed_indices(&indices),
1434 vec![0, 1, 2, 1, 2, 3],
1435 "the indices address the pool without an offset"
1436 );
1437
1438 // The vertex buffer holds the primary stream then the interleaved
1439 // secondary stream, each entry one stride wide.
1440 let secondary_start = 4 * POSITION_STRIDE;
1441 assert_eq!(vertices.len(), secondary_start + 4 * SECONDARY_STRIDE);
1442 let positions: Vec<f32> = vertices[..secondary_start]
1443 .as_chunks::<{ size_of::<f32>() }>()
1444 .0
1445 .iter()
1446 .map(|word| f32::from_le_bytes(*word))
1447 .collect();
1448 assert_eq!(
1449 positions,
1450 vec![0.0, 0.0, 1.0, 2.0, 2.0, 4.0, 3.0, 6.0],
1451 "each position is x and y; the shader supplies the zero z"
1452 );
1453
1454 // The colors are egui's premultiplied bytes, unchanged.
1455 let first_color = secondary_start + size_of::<egui::Vec2>();
1456 assert_eq!(
1457 &vertices[first_color..first_color + size_of::<egui::Color32>()],
1458 &[1, 2, 3, 4]
1459 );
1460
1461 // The indices are this primitive's, starting at zero for the first.
1462 let indices: Vec<u32> = indices
1463 .as_chunks::<{ size_of::<u32>() }>()
1464 .0
1465 .iter()
1466 .map(|word| u32::from_le_bytes(*word))
1467 .collect();
1468 assert_eq!(indices, vec![0, 1, 2, 1, 2, 3]);
1469 }
1470
1471 /// A second primitive's indices are offset by the first's vertex count, so
1472 /// one base vertex addresses both streams.
1473 #[test]
1474 fn a_later_primitive_is_offset_by_the_earlier_one() {
1475 let primitives = [
1476 primitive(egui::TextureId::default(), 3),
1477 primitive(egui::TextureId::default(), 3),
1478 ];
1479 let mut vertices = Vec::new();
1480 let mut indices = Vec::new();
1481 let mut draws = Vec::new();
1482 let tagged = local(&primitives);
1483 let counts = measure(tagged.iter().copied());
1484 pack_geometry(
1485 tagged.iter().copied(),
1486 test_screen(),
1487 &HashMap::new(),
1488 counts,
1489 &mut vertices,
1490 &mut indices,
1491 &mut draws,
1492 );
1493
1494 assert_eq!(draws.len(), 2);
1495 assert_eq!(draws[0].indices, 0..3);
1496 assert_eq!(draws[1].indices, 3..6);
1497 // The second primitive's vertices start at ordinal 3, so its local
1498 // indices are shifted by 3 — the offset a device without `base_vertex`
1499 // could not take from the draw.
1500 assert_eq!(
1501 packed_indices(&indices),
1502 vec![0, 1, 2, 3, 4, 5],
1503 "the second primitive's indices are offset to the pool"
1504 );
1505 }
1506
1507 /// The indices `pack_geometry` wrote, as `u32`s.
1508 fn packed_indices(bytes: &[u8]) -> Vec<u32> {
1509 bytes
1510 .as_chunks::<{ size_of::<u32>() }>()
1511 .0
1512 .iter()
1513 .map(|word| u32::from_le_bytes(*word))
1514 .collect()
1515 }
1516
1517 /// The byte buffers are cleared and refilled, not reallocated, so a caller
1518 /// that keeps them uploads without allocating.
1519 #[test]
1520 fn packing_reuses_the_vertex_and_index_buffers() {
1521 let big = [primitive(egui::TextureId::default(), 8)];
1522 let small = [primitive(egui::TextureId::default(), 3)];
1523
1524 let mut vertices = Vec::new();
1525 let mut indices = Vec::new();
1526 let mut draws = Vec::new();
1527 let big_tagged = local(&big);
1528 let big_counts = measure(big_tagged.iter().copied());
1529 pack_geometry(
1530 big_tagged.iter().copied(),
1531 test_screen(),
1532 &HashMap::new(),
1533 big_counts,
1534 &mut vertices,
1535 &mut indices,
1536 &mut draws,
1537 );
1538 let vertex_capacity = vertices.capacity();
1539 let index_capacity = indices.capacity();
1540
1541 // A later, smaller frame keeps both allocations rather than taking new
1542 // ones; a fresh `Vec` per frame would have exactly its own length.
1543 let small_tagged = local(&small);
1544 let small_counts = measure(small_tagged.iter().copied());
1545 pack_geometry(
1546 small_tagged.iter().copied(),
1547 test_screen(),
1548 &HashMap::new(),
1549 small_counts,
1550 &mut vertices,
1551 &mut indices,
1552 &mut draws,
1553 );
1554 assert_eq!(
1555 vertices.capacity(),
1556 vertex_capacity,
1557 "the vertex scratch buffer is reused"
1558 );
1559 assert_eq!(
1560 indices.capacity(),
1561 index_capacity,
1562 "the index scratch buffer is reused"
1563 );
1564 assert_eq!(draws.len(), 1, "and the stale second draw is gone");
1565 }
1566}