facett-core/src/render/gpu/picking.rs
//! **GPU picking — the colour-encoded ID pass** (GFX_V2 §7 item 8, feature `wgpu`).
//!
//! §7 calls this *"the one deck.gl capability we simply lack"*. Deck.gl renders every
//! pickable primitive a second time into an offscreen target where the **colour IS
//! the id**, then reads back the one texel under the cursor. That answers, in one
//! device round-trip and independent of how the geometry was built, the question a
//! CPU picker can only answer by re-deriving the geometry on the host: *what is under
//! this pixel?*
//!
//! ## This is the shader half. The id space already existed.
//!
//! [`crate::engine::pick`] defined [`PickId`] and the CPU lane, and said so:
//! *"Pass 1 defines the id encoding and the CPU lane; the offscreen target is the
//! shader pass."* This module is that pass, and it **reuses that encoding
//! unchanged** — there is exactly ONE `PickId`, one `to_rgba`/`from_rgba`, one
//! reserved miss sentinel, shared by the CPU lane, the 2-D lane and the 3-D lane
//! (LAW #5). A second id encoding for the GPU would be the twin the law forbids, and
//! worse: two encodings cannot be tested against each other for *agreement*, only
//! for self-consistency, which is the shape of a guard that cannot go red.
//!
//! ## The id space, stated exactly
//!
//! `PickId` is a `u32`: **`layer << 24 | feature`**, `layer` in `1..=255`, `feature`
//! in `0..=`[`MAX_FEATURE`] (`2^24 - 1` = 16 777 215). So:
//!
//! | | value |
//! |---|---|
//! | miss sentinel | `0` — [`PickId::NOTHING`], unreachable from `PickId::new` |
//! | smallest real id | `0x0100_0000` (layer 1, feature 0) |
//! | largest real id | `0xFFFF_FFFF` (layer 255, feature 16 777 215) |
//! | distinct real ids | `255 × 2^24` = 4 278 190 080 |
//!
//! Out-of-range inputs encode as `NOTHING`, never as an alias onto a different
//! feature — a silent alias is a wrong click, forever.
//!
//! **A miss is distinguishable from feature 0 by construction, not by convention.**
//! `layer == 0` is reserved, so the lowest id any real feature can carry is
//! `0x0100_0000`. A cleared target reads `0`; nothing that draws can write `0`. This
//! is why the sentinel lives in the *layer* bits rather than being "feature
//! `u32::MAX`" or an alpha flag: it costs no channel, and it is the natural value of
//! an untouched texel.
//!
//! ## Why `R32Uint`, and why that is not a detail
//!
//! [`PICK_FORMAT`] is `R32Uint`. Three corruptions destroy an id pass, and this
//! format makes all three **impossible rather than merely unused** — WebGPU declares
//! `r32uint` non-filterable, non-blendable and non-multisampleable, so a wrong
//! pipeline descriptor is a validation error at build time instead of an id that
//! silently reads as a neighbour's:
//!
//! * **filtering** — a bilinear tap between id `0x0100_0002` and `0x0100_0004`
//! yields `0x0100_0003`: a *third* object that was never under the cursor.
//! * **blending** — the same, via the colour attachment's blend state.
//! * **MSAA resolve** — averages ids across an edge, so every silhouette becomes a
//! ring of wrong answers.
//!
//! `r32uint` also has no sRGB variant, so no transfer function can touch the value.
//! [`pick_format_cannot_filter_blend_or_msaa`](self#tests) asserts all of that
//! against wgpu's own format table rather than trusting this comment.
//!
//! The 32-bit id therefore crosses the wire in ONE channel — the 8-bit-channel carry
//! problem does not arise. But the *readback* is still four little-endian bytes per
//! texel, and those bytes are decoded by the very same [`PickId::from_rgba`] the CPU
//! lane's byte view uses, so the RGBA byte order is load-bearing and is tested at
//! every carry boundary (`0xFF → 0x100`, `0xFFFF → 0x10000`, `0xFFFFFF → layer`).
//!
//! ## Shared with accessibility? Deliberately not the same id — the same *discipline*
//!
//! [`crate::a11y`] gives a painted element an AccessKit node under a **stable domain
//! key**, and picking answers the same question ("what is under here"). They cannot
//! share one integer: an AccessKit id must be stable across frames and layout
//! changes, while a `PickId`'s `feature` is an index into *this frame's* draw list,
//! and `layer` is a slot in *this frame's* layer stack. Forcing one id space would
//! make the pick target's width a function of the whole application's element count.
//!
//! What they do share is the rule that makes a11y ids trustworthy: **the id is
//! derived, never invented at the call site**. A caller hands
//! [`PickBatch::push_quad`] its own feature index and layer slot and gets a checked
//! encode back; it never composes bits itself. Mapping a resolved `PickId` onto a
//! stable domain key is the host's one-line lookup into the same list it drew from —
//! and keeping that translation at the host is what lets one pick target serve a map
//! layer and a graph overlay in one frame (`engine::pick`'s use case 7).
use crate::engine::pick::PickId;
use egui::{Pos2, Rect};
use wgpu::TextureFormat;
/// The id attachment format: **`R32Uint`**. Non-filterable, non-blendable,
/// non-multisampleable and non-sRGB per the WebGPU format table — see the module
/// docs for why each of those matters, and
/// `pick_format_cannot_filter_blend_or_msaa` for the assertion.
pub const PICK_FORMAT: TextureFormat = TextureFormat::R32Uint;
/// The depth attachment used when the pick pass resolves occlusion. Matches
/// [`super::offscreen::DEPTH_FORMAT`] so a 3-D skin's pick pass declares the same
/// depth format its colour pass does.
pub const PICK_DEPTH_FORMAT: TextureFormat = super::offscreen::DEPTH_FORMAT;
/// The id-writing vertex/fragment shader. Needs the [`super::COMMON_WGSL`] prelude
/// (it takes `px_to_ndc` from there rather than re-spelling it) — compose with
/// [`super::wgsl`].
pub use crate::render::wgsl::PICK_WGSL;
/// One pick vertex: physical-pixel position, clip depth, encoded id. 16 bytes,
/// matching `PickVertexIn` in `pick.wgsl` (Float32x2 @ 0, Float32 @ 8, Uint32 @ 12).
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct PickVertex {
/// Position in physical pixels, origin top-left.
pub pos_px: [f32; 2],
/// Clip-space depth, `0.0` nearest … `1.0` farthest.
pub depth: f32,
/// The encoded [`PickId`] as a raw `u32`.
pub id: u32,
}
/// Byte stride of [`PickVertex`] — the vertex buffer layout's `array_stride`.
pub const PICK_VERTEX_STRIDE: u64 = std::mem::size_of::<PickVertex>() as u64;
/// A frame's pickable geometry, in physical pixels. Push shapes, then upload once.
///
/// This is the ONE place a `PickId` is composed for the GPU lane: a caller hands its
/// own `(layer, feature)` and gets [`PickId::new`]'s checked encode, so an
/// out-of-range feature becomes the miss sentinel instead of aliasing onto another
/// object. A caller that built raw `u32`s itself would reintroduce exactly the
/// silent-alias bug `PickId::new` exists to prevent.
#[derive(Clone, Debug, Default)]
pub struct PickBatch {
verts: Vec<PickVertex>,
}
impl PickBatch {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Forget last frame's geometry, keeping the allocation.
pub fn clear(&mut self) {
self.verts.clear();
}
/// **The writer.** Add an axis-aligned rect (two triangles) carrying `id` at
/// clip depth `depth`.
///
/// Depth is what makes a 3-D pick return the object a viewer can *see*: with a
/// depth attachment attached and `Less` compare, the nearest id wins regardless
/// of submission order. Without it the answer is "whatever drew last", which
/// happens to be right for a painter-ordered 2-D scene and wrong for a camera.
pub fn push_quad_at_depth(&mut self, rect: Rect, id: PickId, depth: f32) {
let (l, t, r, b) = (rect.left(), rect.top(), rect.right(), rect.bottom());
let raw = id.0;
let v = |x: f32, y: f32| PickVertex { pos_px: [x, y], depth, id: raw };
self.verts.extend_from_slice(&[
v(l, t),
v(r, t),
v(r, b),
v(l, t),
v(r, b),
v(l, b),
]);
}
/// [`push_quad_at_depth`](Self::push_quad_at_depth) at depth `0.0` — the 2-D
/// lane, where painter order alone decides and everything sits on the near
/// plane. Delegates so there is one quad-winding writer (LAW #5); a second
/// six-vertex expansion differing only in a `0.0` is exactly the twin.
pub fn push_quad(&mut self, rect: Rect, id: PickId) {
self.push_quad_at_depth(rect, id, 0.0);
}
/// Add a rect for `(layer, feature)`, encoding through [`PickId::new`].
pub fn push_feature(&mut self, rect: Rect, layer: u8, feature: u32) {
self.push_quad(rect, PickId::new(layer, feature));
}
/// Add a rect at its **painter-order depth**, so a depth-tested pick pass
/// resolves overlaps the way [`PickIndex`](crate::engine::pick::PickIndex)'s
/// `Box` lane does.
///
/// This exists because the two lanes' tie-breaks are **opposite by default**, and
/// that is a wrong click rather than a cosmetic difference. `PickIndex`'s `Box`
/// shape documents *"the LOWEST feature index that contains the probe wins —
/// paint order"*; a colour-encoded pass with no depth gives the answer *"whichever
/// quad was submitted LAST"*. For overlapping chips those are different objects,
/// which is exactly the failure `PickShape`'s own doc warns about: *"a picker that
/// silently swaps one for the other is wrong in a way no smoke test notices."*
///
/// Mapping paint index → depth (lower index = nearer) makes the `Less` depth test
/// reproduce the CPU rule, so agreement is by construction and does not depend on
/// the caller's submission order at all.
pub fn push_quad_in_painter_order(&mut self, rect: Rect, id: PickId, paint_index: usize, paint_count: usize) {
self.push_quad_at_depth(rect, id, painter_depth(paint_index, paint_count));
}
/// Add an arbitrary triangle — the escape hatch for tessellated geometry (a road
/// quad strip, an extruded building cap) that is not an axis-aligned rect.
pub fn push_tri(&mut self, a: Pos2, b: Pos2, c: Pos2, id: PickId, depth: f32) {
let raw = id.0;
for p in [a, b, c] {
self.verts.push(PickVertex { pos_px: [p.x, p.y], depth, id: raw });
}
}
/// The raw vertices, for a caller that uploads them itself.
#[must_use]
pub fn vertices(&self) -> &[PickVertex] {
&self.verts
}
/// Vertex count — what [`PickPass::record`] draws.
#[must_use]
pub fn len(&self) -> usize {
self.verts.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.verts.is_empty()
}
}
/// The offscreen id target + the one-texel readback. Owns an [`PICK_FORMAT`]
/// texture, optionally a depth companion, and (re)allocates on resize.
pub struct PickTarget {
tex: Option<wgpu::Texture>,
view: Option<wgpu::TextureView>,
depth_view: Option<wgpu::TextureView>,
size: (u32, u32),
with_depth: bool,
}
impl PickTarget {
/// A colour-only id target — the 2-D lane, where painter order decides.
#[must_use]
pub fn new() -> Self {
Self { tex: None, view: None, depth_view: None, size: (0, 0), with_depth: false }
}
/// An id target with a [`PICK_DEPTH_FORMAT`] companion, so the pass resolves
/// occlusion and the **nearest** id wins — the 3-D lane.
#[must_use]
pub fn with_depth() -> Self {
Self { tex: None, view: None, depth_view: None, size: (0, 0), with_depth: true }
}
/// Whether this target carries a depth attachment.
#[must_use]
pub fn has_depth(&self) -> bool {
self.with_depth
}
/// (Re)create the target for `w × h` physical pixels. Idempotent within a size.
///
/// `sample_count` is hard-wired to 1: MSAA averages ids across silhouettes. It
/// is not merely unset here — `R32Uint` cannot be multisampled at all, so a
/// future edit that raised it would fail wgpu validation rather than quietly
/// smear the id space.
pub fn ensure(&mut self, device: &wgpu::Device, w: u32, h: u32) {
let w = w.max(1);
let h = h.max(1);
if self.size == (w, h) && self.view.is_some() {
return;
}
let size = wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 };
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("l0_pick_id_target"),
size,
mip_level_count: 1,
sample_count: crate::render::gpu::NO_MSAA_SAMPLES,
dimension: wgpu::TextureDimension::D2,
format: PICK_FORMAT,
// COPY_SRC is the readback; no TEXTURE_BINDING, because nothing may ever
// SAMPLE this target — sampling is how filtering gets in.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = tex.create_view(&Default::default());
self.depth_view = self.with_depth.then(|| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("l0_pick_depth"),
size,
mip_level_count: 1,
sample_count: crate::render::gpu::NO_MSAA_SAMPLES,
dimension: wgpu::TextureDimension::D2,
format: PICK_DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&Default::default())
});
self.tex = Some(tex);
self.view = Some(view);
self.size = (w, h);
}
/// Whether targets are allocated.
#[must_use]
pub fn ready(&self) -> bool {
self.view.is_some()
}
/// Current size in physical pixels.
#[must_use]
pub fn size(&self) -> (u32, u32) {
self.size
}
/// The id attachment view, for a caller recording its own pass.
#[must_use]
pub fn view(&self) -> Option<&wgpu::TextureView> {
self.view.as_ref()
}
/// The depth attachment view (`None` unless built with [`Self::with_depth`]).
#[must_use]
pub fn depth_view(&self) -> Option<&wgpu::TextureView> {
self.depth_view.as_ref()
}
/// Begin the id pass, **clearing to [`PickId::NOTHING`]**.
///
/// The clear value is `0` and nothing else, on purpose. A render-pass clear
/// colour is a `wgpu::Color` of `f64`s reinterpreted per the attachment's sample
/// type; `0` is the one value that means the same thing under every
/// interpretation. That the reserved miss sentinel is exactly `0` is therefore
/// not a coincidence — it is what lets "no geometry here" and "the cleared
/// target" be the same bit pattern with no conversion risk.
///
/// Depth clears to `1.0` (far) so the `Less` compare admits the first fragment.
pub fn begin_pass<'a>(&'a self, enc: &'a mut wgpu::CommandEncoder) -> Option<wgpu::RenderPass<'a>> {
let view = self.view.as_ref()?;
Some(enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("l0_pick_id_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), // == PickId::NOTHING
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: self.depth_view.as_ref().map(|dv| wgpu::RenderPassDepthStencilAttachment {
view: dv,
depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store }),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
}))
}
/// **The click.** Read the id at physical texel `(x, y)`.
///
/// Out of bounds is [`PickId::NOTHING`] — a cursor outside the pane is a miss,
/// not a clamp onto the edge feature.
#[must_use]
pub fn read_id(&self, device: &wgpu::Device, queue: &wgpu::Queue, x: u32, y: u32) -> PickId {
self.read_region(device, queue, x, y, 1, 1).first().copied().unwrap_or(PickId::NOTHING)
}
/// [`read_id`](Self::read_id) from a **logical** cursor position relative to the
/// widget's top-left, at `pixels_per_point`. The conversion is
/// [`logical_to_texel`], shared with the CPU lane's probe so a hover highlight
/// and a click cannot land on different texels.
#[must_use]
pub fn read_id_logical(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
local: Pos2,
pixels_per_point: f32,
) -> PickId {
match logical_to_texel(local, pixels_per_point) {
Some((x, y)) => self.read_id(device, queue, x, y),
None => PickId::NOTHING,
}
}
/// Read a `w × h` block of ids at `(x, y)`, row-major. **One writer** for the
/// readback: [`read_id`](Self::read_id) is this with `1 × 1`, so the one-texel
/// click and a neighbourhood probe cannot decode bytes two different ways.
///
/// Returns empty when the region does not intersect the target, or when no
/// target is allocated.
#[must_use]
pub fn read_region(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
x: u32,
y: u32,
w: u32,
h: u32,
) -> Vec<PickId> {
let Some(tex) = &self.tex else { return Vec::new() };
let (tw, th) = self.size;
if x >= tw || y >= th || w == 0 || h == 0 {
return Vec::new();
}
let w = w.min(tw - x);
let h = h.min(th - y);
// The copy + row-padding strip + `map_async` poll now come from
// `render::gpu::readback`, the ONE writer of that dance (LAW #5). What stays
// here is the only part that is about *ids*: the decode.
let data = super::readback::read_texture_region(device, queue, tex, 4, x, y, w, h);
let mut ids = Vec::with_capacity((w * h) as usize);
for chunk in data.as_chunks::<4>().0 {
// The SAME byte view the CPU lane uses. `copy_texture_to_buffer`
// hands back the texel's 4 little-endian bytes, which IS
// `PickId::to_rgba`, so the two lanes decode one number one way.
ids.push(PickId::from_rgba([chunk[0], chunk[1], chunk[2], chunk[3]]));
}
ids
}
}
impl Default for PickTarget {
fn default() -> Self {
Self::new()
}
}
/// Paint index → clip depth, **lower index nearer**, strictly inside `0..1` so no
/// object lands on the depth clear value.
///
/// The one writer of the painter-order → depth rule (see
/// [`PickBatch::push_quad_in_painter_order`]). Monotonic and injective for
/// `index < count`, which is what makes the `Less` test a total order over the paint
/// list rather than a coin flip between two objects at equal depth.
#[must_use]
pub fn painter_depth(index: usize, count: usize) -> f32 {
let n = count.max(1) as f32;
((index as f32 + 0.5) / n).clamp(0.0, 1.0)
}
/// A logical cursor position relative to a widget's top-left → the physical texel to
/// probe in that widget's pick target. `None` when the position is negative (the
/// cursor is outside the widget), which reads as a miss rather than clamping onto
/// the edge feature.
///
/// Plain arithmetic, no device — so the coordinate step of a click is testable
/// without a GPU, and the GPU test can assert the same function it ships.
#[must_use]
pub fn logical_to_texel(local: Pos2, pixels_per_point: f32) -> Option<(u32, u32)> {
let ppp = if pixels_per_point > 0.0 { pixels_per_point } else { 1.0 };
let x = local.x * ppp;
let y = local.y * ppp;
if x.is_nan() || x < 0.0 || y.is_nan() || y < 0.0 {
return None; // also rejects NaN
}
Some((x as u32, y as u32))
}
/// The id pass pipeline + its viewport uniform. Build once from the device; call
/// [`set_viewport`](Self::set_viewport) when the pane resizes,
/// [`upload`](Self::upload) with the frame's [`PickBatch`], then
/// [`record`](Self::record) inside [`PickTarget::begin_pass`].
pub struct PickPass {
pipeline: wgpu::RenderPipeline,
ubo: wgpu::Buffer,
bind: wgpu::BindGroup,
verts: Option<wgpu::Buffer>,
vert_cap: u64,
vert_count: u32,
}
impl PickPass {
/// Build the pick pipeline. `depth` must be `Some(PICK_DEPTH_FORMAT)` exactly
/// when the [`PickTarget`] it will draw into has a depth attachment — the
/// pipeline's depth state and the pass's attachments must agree or wgpu rejects
/// the pass.
///
/// One constructor for both lanes, parameterised: the 2-D pick pass and the 3-D
/// pick pass differ ONLY in this `Option`, and a second `new_with_depth` copying
/// forty lines of descriptor to change it would be the twin LAW #5 forbids.
pub fn new(device: &wgpu::Device, depth: Option<TextureFormat>) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("l0_pick"),
source: wgpu::ShaderSource::Wgsl(super::wgsl(PICK_WGSL).into()),
});
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("l0_pick_bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let ubo = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("l0_pick_ubo"),
size: 16, // vec2<f32> viewport + vec2<f32> pad
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("l0_pick_bind"),
layout: &bgl,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: ubo.as_entire_binding() }],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("l0_pick_pipeline"),
layout: Some(&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("l0_pick_pll"),
bind_group_layouts: &[Some(&bgl)],
immediate_size: 0,
})),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("pick_vs"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: PICK_VERTEX_STRIDE,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 0, shader_location: 0 },
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32, offset: 8, shader_location: 1 },
wgpu::VertexAttribute { format: wgpu::VertexFormat::Uint32, offset: 12, shader_location: 2 },
],
})],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
// No back-face culling: a pickable quad's winding is the caller's
// business, and a silently unpickable object is the worst failure
// this pass can have.
cull_mode: None,
..Default::default()
},
depth_stencil: depth.map(|format| wgpu::DepthStencilState {
format,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: Default::default(),
bias: Default::default(),
}),
// sample_count 1. `R32Uint` cannot be multisampled, so this is enforced
// by wgpu validation, not by this line staying put.
multisample: crate::render::gpu::msaa_state(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("pick_fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: PICK_FORMAT,
// `None`, and it could not be anything else: `R32Uint` is not
// blendable, so a blend state here is a validation error. An id
// must arrive whole or not at all.
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
Self { pipeline, ubo, bind, verts: None, vert_cap: 0, vert_count: 0 }
}
/// Tell the shader the target size in physical pixels — the space
/// [`PickVertex::pos_px`] is in.
pub fn set_viewport(&self, queue: &wgpu::Queue, w: u32, h: u32) {
let u: [f32; 4] = [w.max(1) as f32, h.max(1) as f32, 0.0, 0.0];
queue.write_buffer(&self.ubo, 0, bytemuck::cast_slice(&u));
}
/// Upload a frame's pickable geometry, growing the vertex buffer as needed.
pub fn upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, batch: &PickBatch) {
let verts = batch.vertices();
self.vert_count = verts.len() as u32;
if verts.is_empty() {
return;
}
let bytes: &[u8] = bytemuck::cast_slice(verts);
let need = bytes.len() as u64;
if self.verts.is_none() || self.vert_cap < need {
let cap = need.next_power_of_two().max(1024);
self.verts = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some("l0_pick_verts"),
size: cap,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
self.vert_cap = cap;
}
queue.write_buffer(self.verts.as_ref().expect("just allocated"), 0, bytes);
}
/// Record the id draw into a pass from [`PickTarget::begin_pass`]. Returns the
/// vertex count submitted — `0` means nothing was drawn, so a caller (or a test)
/// can tell "everything missed" from "the pass never ran", which is the
/// distinction a `bool` loses.
pub fn record(&self, pass: &mut wgpu::RenderPass<'_>) -> u32 {
if self.vert_count == 0 {
return 0;
}
let Some(vb) = &self.verts else { return 0 };
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind, &[]);
pass.set_vertex_buffer(0, vb.slice(..));
pass.draw(0..self.vert_count, 0..1);
self.vert_count
}
/// Vertex count currently uploaded.
#[must_use]
pub fn vertex_count(&self) -> u32 {
self.vert_count
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::pick::MAX_FEATURE;
/// **The format is the guard.** Asserted against wgpu's own format table, not
/// against a comment: `R32Uint` cannot filter, cannot blend, cannot multisample
/// and is not sRGB — the four ways an id gets corrupted into a neighbour's.
///
/// RED-PROVEN on a real device (2026-08-01): `PICK_FORMAT = Rgba8Unorm` — the
/// obvious "colour-encoded" choice — fails here with *"Rgba8Unorm permits
/// FILTERABLE — filtering blends two ids into a third"*, and `Rgba8UnormSrgb`
/// fails identically. Measured, not assumed: FILTERABLE is checked first, so the
/// `is_srgb` assertion below has NOT been observed red on its own — it guards a
/// format that would have to be non-filterable *and* sRGB to reach it, which no
/// current wgpu format is. It is a belt on braces, and saying so is the point.
#[test]
fn pick_format_cannot_filter_blend_or_msaa() {
let f = PICK_FORMAT.guaranteed_format_features(wgpu::Features::empty());
assert!(
f.allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT),
"the id target must be renderable"
);
assert!(f.allowed_usages.contains(wgpu::TextureUsages::COPY_SRC), "the id target must be readable back");
let bad = [
(wgpu::TextureFormatFeatureFlags::FILTERABLE, "filtering blends two ids into a third"),
(wgpu::TextureFormatFeatureFlags::BLENDABLE, "blending blends two ids into a third"),
(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_X4, "MSAA averages ids across every silhouette"),
(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_X2, "MSAA averages ids across every silhouette"),
];
for (flag, why) in bad {
assert!(!f.flags.contains(flag), "{PICK_FORMAT:?} permits {flag:?} — {why}");
}
assert!(!PICK_FORMAT.is_srgb(), "an sRGB transfer function would rewrite the id");
assert_eq!(PICK_FORMAT.target_pixel_byte_cost(), Some(4), "a 32-bit id needs 4 bytes/texel");
}
/// The wire format: the four bytes `copy_texture_to_buffer` returns for an
/// `R32Uint` texel are little-endian, which is exactly `PickId::to_rgba`. So the
/// GPU readback decode and the CPU lane's byte view are ONE function — checked
/// here at every **channel-carry boundary**, the ids a hand-rolled shift/mask
/// decode gets wrong.
#[test]
fn every_carry_boundary_round_trips_through_the_rgba_byte_view() {
// feature values that straddle a byte boundary, plus the ceiling.
let features = [
0,
1,
2,
0xFE,
0xFF,
0x100, // R carries into G
0x101,
0xFFFE,
0xFFFF,
0x1_0000, // G carries into B
0x1_0001,
0xFF_FFFE,
MAX_FEATURE, // 0xFF_FFFF — B full, next carry would hit the layer bits
];
for layer in [1u8, 2, 0x7F, 0x80, 0xFE, 0xFF] {
for feat in features {
let id = PickId::new(layer, feat);
assert!(!id.is_nothing(), "layer {layer} feature {feat:#x} must be a real id");
assert_eq!(id.layer(), layer, "layer lost for feature {feat:#x}");
assert_eq!(id.feature(), feat, "feature {feat:#x} lost for layer {layer}");
let bytes = id.to_rgba();
assert_eq!(bytes, id.0.to_le_bytes(), "the byte view must BE the wire bytes");
assert_eq!(PickId::from_rgba(bytes), id, "round trip failed at layer {layer} feature {feat:#x}");
}
}
// The ceiling, stated: 0xFFFF_FFFF is reachable and is the largest id.
let top = PickId::new(0xFF, MAX_FEATURE);
assert_eq!(top.0, u32::MAX, "the id space tops out at 0xFFFF_FFFF");
assert_eq!(PickId::from_rgba([0xFF; 4]), top);
}
/// The miss sentinel is unreachable from a real feature — **by construction**,
/// not by convention. The lowest id any drawable feature can carry is
/// `0x0100_0000`, so a cleared texel (`0`) can never be confused with
/// "feature 0", which is the trap in every naive id pass.
#[test]
fn the_miss_sentinel_is_below_every_real_id() {
assert_eq!(PickId::NOTHING.0, 0);
let lowest = PickId::new(1, 0);
assert_eq!(lowest.0, 0x0100_0000, "layer 1 feature 0 is the floor of the real id space");
assert!(lowest.0 > PickId::NOTHING.0);
for layer in 1..=255u8 {
assert!(!PickId::new(layer, 0).is_nothing(), "layer {layer} feature 0 must not read as a miss");
}
// A batch cannot smuggle a 0 in for a real feature either.
let mut b = PickBatch::new();
b.push_feature(Rect::from_min_size(Pos2::ZERO, egui::vec2(4.0, 4.0)), 1, 0);
assert!(b.vertices().iter().all(|v| v.id == 0x0100_0000), "push_feature must encode, not pass through");
}
/// The vertex layout matches `pick.wgsl`'s attribute offsets. A stride or offset
/// drift silently reads the id out of the position bytes.
#[test]
fn vertex_layout_matches_the_shader() {
assert_eq!(PICK_VERTEX_STRIDE, 16);
assert_eq!(std::mem::offset_of!(PickVertex, pos_px), 0);
assert_eq!(std::mem::offset_of!(PickVertex, depth), 8);
assert_eq!(std::mem::offset_of!(PickVertex, id), 12);
assert!(PICK_WGSL.contains("@location(2) id: u32"), "shader must read the id at location 2");
assert!(PICK_WGSL.contains("@interpolate(flat)"), "an id must never be interpolated");
assert!(PICK_WGSL.contains("-> @location(0) u32"), "the fragment stage writes a raw u32 id");
assert!(!PICK_WGSL.contains("fn px_to_ndc"), "pick.wgsl must take px_to_ndc from the ONE prelude");
assert!(super::super::wgsl(PICK_WGSL).contains("fn px_to_ndc"), "the composer supplies it");
}
/// A quad is six vertices covering the rect, all carrying the same id.
#[test]
fn a_quad_is_six_vertices_of_one_id_over_its_rect() {
let r = Rect::from_min_max(Pos2::new(10.0, 20.0), Pos2::new(30.0, 50.0));
let id = PickId::new(2, 7);
let mut b = PickBatch::new();
b.push_quad(r, id);
assert_eq!(b.len(), 6);
assert!(b.vertices().iter().all(|v| v.id == id.0 && v.depth == 0.0));
for c in [(10.0, 20.0), (30.0, 20.0), (30.0, 50.0), (10.0, 50.0)] {
assert!(
b.vertices().iter().any(|v| v.pos_px == [c.0, c.1]),
"corner {c:?} missing — the quad does not cover its rect"
);
}
// push_quad delegates to push_quad_at_depth: same winding, one writer.
let mut d = PickBatch::new();
d.push_quad_at_depth(r, id, 0.0);
assert_eq!(b.vertices(), d.vertices());
}
/// The logical→texel step, and the out-of-widget miss.
#[test]
fn logical_to_texel_scales_and_rejects_outside() {
assert_eq!(logical_to_texel(Pos2::new(10.0, 20.0), 1.0), Some((10, 20)));
assert_eq!(logical_to_texel(Pos2::new(10.0, 20.0), 2.0), Some((20, 40)));
assert_eq!(logical_to_texel(Pos2::new(10.4, 20.9), 1.5), Some((15, 31)));
assert_eq!(logical_to_texel(Pos2::new(-0.5, 20.0), 1.0), None, "left of the widget is a miss");
assert_eq!(logical_to_texel(Pos2::new(10.0, -3.0), 1.0), None, "above the widget is a miss");
assert_eq!(logical_to_texel(Pos2::new(f32::NAN, 0.0), 1.0), None, "NaN is a miss, not texel 0");
// ppp <= 0 would collapse every position onto texel 0; treat it as 1.
assert_eq!(logical_to_texel(Pos2::new(7.0, 9.0), 0.0), Some((7, 9)));
}
}