Viewport picking: a BVH per body mesh and a GPU pick buffer for visible edges (#1776)

Thanks @oursland.
This commit is contained in:
Jackson Oursland authored and GitHub committed 2026-10-16 14:26:38 -04:00
1 parent a9413fe385
commit 8907da5e86
14 files changed
+1707 -50

No files matched your search

+1
View File
@@ -21,6 +21,7 @@ use wgpu::util::DeviceExt;
pub mod capture;
mod pbr;
pub mod pick;
#[cfg(not(target_arch = "wasm32"))]
pub mod select;
pub mod view;
+503
View File
@@ -0,0 +1,503 @@
//! The pick buffer: which model edges are visible at each pixel of the viewport, for picking on a
//! large model without testing every edge. Surfaces are drawn into the depth buffer only; then
//! every edge segment is drawn as a line carrying its id, depth tested against them (lifted
//! toward the eye by the picker's slack, the depth by which an edge may lie behind a face and
//! still count as visible) and written into an `R32Uint` id texture (0: no edge).
//!
//! The pass is drawn once per view (camera, viewport size, model), and only small tiles around
//! the cursor are read back, so a pointer moving over a still view reads a few kilobytes and
//! draws nothing. Read-backs arrive a frame or two later ([`PickResults`]); until a tile is in,
//! the picker answers on the CPU. The ids are hints: the picker still runs its exact tests on
//! the edges a tile names.
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use wgpu::util::DeviceExt;
/// The side of a read-back tile, in pixels (a multiple of 64, so rows need no padding).
pub const TILE: u32 = 128;
/// Most tiles kept from one view.
const KEEP: usize = 6;
/// Most read-backs in flight.
const IN_FLIGHT: usize = 3;
/// Bytes per surface vertex (position) and per edge vertex (position, id).
const FACE_VERTEX: u64 = 12;
const EDGE_VERTEX: u64 = 16;
const UNIFORM_SIZE: u64 = 96;
const SHADER: &str = r#"
struct U {
vp: mat4x4<f32>,
eye: vec4<f32>, // eye position, w = 1 for perspective
back: vec4<f32>, // toward the eye (orthographic), w = how far edges are lifted toward it
};
@group(0) @binding(0) var<uniform> u: U;
@vertex
fn vs_face(@location(0) p: vec3<f32>) -> @builtin(position) vec4<f32> {
return u.vp * vec4<f32>(p, 1.0);
}
@fragment
fn fs_face() -> @location(0) u32 {
return 0u;
}
struct EOut {
@builtin(position) pos: vec4<f32>,
@location(0) @interpolate(flat) id: u32,
};
@vertex
fn vs_edge(@location(0) p: vec3<f32>, @location(1) id: u32) -> EOut {
var toward = u.back.xyz;
if (u.eye.w > 0.5) {
let v = u.eye.xyz - p;
let l = length(v);
if (l > 0.0) {
toward = v / l;
}
}
var o: EOut;
o.pos = u.vp * vec4<f32>(p + toward * u.back.w, 1.0);
o.id = id;
return o;
}
@fragment
fn fs_edge(i: EOut) -> @location(0) u32 {
return i.id;
}
"#;
/// What the pass draws: surfaces (for depth) and edge segments with their ids (from 1).
#[derive(Default, Clone)]
pub struct PickScene {
faces: Vec<u8>,
edges: Vec<u8>,
}
impl PickScene {
pub fn triangle(&mut self, t: [[f32; 3]; 3]) {
for p in t {
for c in p {
self.faces.extend_from_slice(&c.to_le_bytes());
}
}
}
/// Segment `a`–`b` of the edge `id` (0 is no edge: not drawn).
pub fn segment(&mut self, a: [f32; 3], b: [f32; 3], id: u32) {
if id == 0 {
return;
}
for p in [a, b] {
for c in p {
self.edges.extend_from_slice(&c.to_le_bytes());
}
self.edges.extend_from_slice(&id.to_le_bytes());
}
}
pub fn is_empty(&self) -> bool {
self.edges.is_empty()
}
}
/// The view the pass is drawn for.
#[derive(Clone, Copy, Debug)]
pub struct PickView {
/// Changes whenever anything below or the scene changes.
pub key: u64,
/// Column-major view-projection (as [`crate::view::Frame::view_proj`]).
pub view_proj: [[f32; 4]; 4],
/// Eye position, and 1 for a perspective camera (0: orthographic).
pub eye: [f32; 4],
/// Direction toward the eye (orthographic).
pub back: [f32; 3],
/// How far edges are lifted toward the eye (model units).
pub lift: f32,
/// The viewport in physical pixels.
pub size: [u32; 2],
}
/// Edge ids of a block of pixels of one view.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PickTile {
pub key: u64,
pub origin: [u32; 2],
pub size: [u32; 2],
/// Row-major, `size[0] * size[1]`.
pub ids: Vec<u32>,
}
impl PickTile {
/// Whether the tile is of view `key` and holds every pixel of `[x0, y0, x1, y1]` (inclusive)
/// that lies inside a viewport of `view` pixels.
pub fn covers(&self, key: u64, view: [u32; 2], [x0, y0, x1, y1]: [i64; 4]) -> bool {
let clamp = |v: i64, n: u32| v.clamp(0, i64::from(n.max(1)) - 1);
let (x0, x1, y0, y1) = (clamp(x0, view[0]), clamp(x1, view[0]), clamp(y0, view[1]), clamp(y1, view[1]));
let [ox, oy] = self.origin.map(i64::from);
let [w, h] = self.size.map(i64::from);
self.key == key && x0 >= ox && y0 >= oy && x1 < ox + w && y1 < oy + h
}
/// The ids within `[x0, y0, x1, y1]` (inclusive; outside the tile: none), each once.
pub fn ids_in(&self, [x0, y0, x1, y1]: [i64; 4]) -> Vec<u32> {
let [ox, oy] = self.origin.map(i64::from);
let [w, h] = self.size.map(i64::from);
let mut out = Vec::new();
for y in y0.max(oy)..=y1.min(oy + h - 1) {
for x in x0.max(ox)..=x1.min(ox + w - 1) {
if let Some(&id) = self.ids.get(((y - oy) * w + (x - ox)) as usize)
&& id != 0
{
out.push(id);
}
}
}
out.sort_unstable();
out.dedup();
out
}
}
/// The tiles read back so far (of the latest view only), newest last.
#[derive(Default, Debug)]
pub struct PickResults {
pub tiles: Vec<PickTile>,
}
impl PickResults {
/// The newest tile of view `key` covering `window`.
pub fn tile(&self, key: u64, view: [u32; 2], window: [i64; 4]) -> Option<&PickTile> {
self.tiles.iter().rev().find(|t| t.covers(key, view, window))
}
}
pub type PickOut = Arc<Mutex<PickResults>>;
/// The tile (origin and size) around pixel `centre` of a viewport of `view` pixels.
pub fn tile_around(centre: [u32; 2], view: [u32; 2]) -> ([u32; 2], [u32; 2]) {
let size = [TILE.min(view[0]), TILE.min(view[1])];
let origin = [0, 1].map(|k| centre[k].saturating_sub(size[k] / 2).min(view[k].saturating_sub(size[k])));
(origin, size)
}
const NEW: u8 = 0;
const MAPPING: u8 = 1;
const READY: u8 = 2;
const FAILED: u8 = 3;
/// A tile being copied out of the id texture.
struct Pending {
key: u64,
origin: [u32; 2],
size: [u32; 2],
bytes_per_row: u32,
buffer: wgpu::Buffer,
/// The frame whose commands copy it (it is mapped once they are submitted).
frame: u64,
state: Arc<AtomicU8>,
}
struct Target {
size: [u32; 2],
ids: wgpu::Texture,
ids_view: wgpu::TextureView,
depth_view: wgpu::TextureView,
}
struct Uploaded {
key: u64,
faces: Option<(wgpu::Buffer, u32)>,
edges: Option<(wgpu::Buffer, u32)>,
}
/// The pass's pipelines, its textures and the read-backs in flight, on one device.
pub struct PickBuffer {
face: wgpu::RenderPipeline,
edge: wgpu::RenderPipeline,
uniform: wgpu::Buffer,
bind: wgpu::BindGroup,
scene: Option<Uploaded>,
target: Option<Target>,
/// The view drawn into the textures.
drawn: Option<u64>,
pending: Vec<Pending>,
frame: u64,
}
const DEPTH: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
const IDS: wgpu::TextureFormat = wgpu::TextureFormat::R32Uint;
impl PickBuffer {
pub fn new(device: &wgpu::Device) -> PickBuffer {
let module =
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_pick"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("sc_pick"),
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 layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("sc_pick"),
bind_group_layouts: &[Some(&bgl)],
immediate_size: 0,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sc_pick_u"),
size: UNIFORM_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("sc_pick"),
layout: &bgl,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }],
});
const FA: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0 => Float32x3];
const EA: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Uint32];
let pipeline = |label: &str,
vs: &str,
fs: &str,
stride: u64,
attributes: &'static [wgpu::VertexAttribute],
topology: wgpu::PrimitiveTopology,
write: bool,
mask: wgpu::ColorWrites| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(vs),
buffers: &[Some(wgpu::VertexBufferLayout { array_stride: stride, step_mode: wgpu::VertexStepMode::Vertex, attributes })],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState { topology, ..Default::default() },
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH,
depth_write_enabled: Some(write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(fs),
targets: &[Some(wgpu::ColorTargetState { format: IDS, blend: None, write_mask: mask })],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
})
};
let face =
pipeline("sc_pick_face", "vs_face", "fs_face", FACE_VERTEX, &FA, wgpu::PrimitiveTopology::TriangleList, true, wgpu::ColorWrites::empty());
let edge = pipeline("sc_pick_edge", "vs_edge", "fs_edge", EDGE_VERTEX, &EA, wgpu::PrimitiveTopology::LineList, false, wgpu::ColorWrites::ALL);
PickBuffer { face, edge, uniform, bind, scene: None, target: None, drawn: None, pending: Vec::new(), frame: 0 }
}
/// Before the frame is submitted: collect the tiles read back, then draw `view` of `scene`
/// (`scene_key` names it; it is uploaded when that changes) unless it is drawn already, and
/// start reading back the tile around pixel `centre` unless it is in or on its way. The tiles
/// land in `out` a frame or two later; keep frames coming until then.
#[allow(clippy::too_many_arguments)]
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
scene_key: u64,
scene: &PickScene,
view: &PickView,
centre: Option<[u32; 2]>,
out: &PickOut,
) {
self.frame += 1;
self.collect(device, out);
let [w, h] = view.size;
let max = device.limits().max_texture_dimension_2d;
if w == 0 || h == 0 || w > max || h > max {
return;
}
if self.scene.as_ref().is_none_or(|s| s.key != scene_key) {
let limit = device.limits().max_buffer_size;
let upload = |label: &str, bytes: &[u8], stride: u64| {
let n = bytes.len() as u64 / stride;
(n > 0 && (bytes.len() as u64) <= limit && n <= u64::from(u32::MAX)).then(|| {
(
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents: bytes,
usage: wgpu::BufferUsages::VERTEX,
}),
n as u32,
)
})
};
self.scene = Some(Uploaded {
key: scene_key,
faces: upload("sc_pick_faces", &scene.faces, FACE_VERTEX),
edges: upload("sc_pick_edges", &scene.edges, EDGE_VERTEX),
});
self.drawn = None;
}
if self.target.as_ref().is_none_or(|t| t.size != view.size) {
let texture = |label: &str, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let ids = texture("sc_pick_ids", IDS, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC);
let depth = texture("sc_pick_depth", DEPTH, wgpu::TextureUsages::RENDER_ATTACHMENT);
let (ids_view, depth_view) = (ids.create_view(&Default::default()), depth.create_view(&Default::default()));
self.target = Some(Target { size: view.size, ids, ids_view, depth_view });
self.drawn = None;
}
let (Some(target), Some(scene)) = (&self.target, &self.scene) else { return };
if self.drawn != Some(view.key) {
queue.write_buffer(&self.uniform, 0, &uniform_bytes(view));
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("sc_pick"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &target.ids_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store },
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &target.depth_view,
depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Discard }),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_bind_group(0, &self.bind, &[]);
for (pipeline, part) in [(&self.face, &scene.faces), (&self.edge, &scene.edges)] {
if let Some((buffer, n)) = part {
pass.set_pipeline(pipeline);
pass.set_vertex_buffer(0, buffer.slice(..));
pass.draw(0..*n, 0..1);
}
}
drop(pass);
self.drawn = Some(view.key);
// Tiles of other views no longer answer.
self.pending.retain(|p| p.key == view.key);
if let Ok(mut o) = out.lock() {
o.tiles.retain(|t| t.key == view.key);
}
}
let Some(centre) = centre else { return };
let (origin, size) = tile_around(centre, view.size);
let known = out.lock().is_ok_and(|o| o.tiles.iter().any(|t| t.key == view.key && t.origin == origin && t.size == size))
|| self.pending.iter().any(|p| p.key == view.key && p.origin == origin && p.size == size);
if known || self.pending.len() >= IN_FLIGHT {
return;
}
let bytes_per_row = (size[0] * 4).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sc_pick_tile"),
size: u64::from(bytes_per_row) * u64::from(size[1]),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &target.ids,
mip_level: 0,
origin: wgpu::Origin3d { x: origin[0], y: origin[1], z: 0 },
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(bytes_per_row), rows_per_image: Some(size[1]) },
},
wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 },
);
self.pending.push(Pending { key: view.key, origin, size, bytes_per_row, buffer, frame: self.frame, state: Arc::new(AtomicU8::new(NEW)) });
}
/// Whether read-backs are still on their way (keep frames coming).
pub fn waiting(&self) -> bool {
!self.pending.is_empty()
}
/// Maps the copies earlier frames submitted and moves the mapped ones into `out`.
fn collect(&mut self, device: &wgpu::Device, out: &PickOut) {
let frame = self.frame;
for p in &self.pending {
if p.frame < frame && p.state.load(Ordering::Acquire) == NEW {
p.state.store(MAPPING, Ordering::Release);
let state = p.state.clone();
p.buffer.slice(..).map_async(wgpu::MapMode::Read, move |r| state.store(if r.is_ok() { READY } else { FAILED }, Ordering::Release));
}
}
if self.pending.iter().any(|p| p.state.load(Ordering::Acquire) == MAPPING) {
let _ = device.poll(wgpu::PollType::Poll);
}
let mut done = Vec::new();
self.pending.retain(|p| match p.state.load(Ordering::Acquire) {
READY => {
if let Ok(bytes) = p.buffer.slice(..).get_mapped_range() {
let [w, h] = p.size;
let mut ids = Vec::with_capacity(w as usize * h as usize);
for row in bytes.chunks(p.bytes_per_row as usize).take(h as usize) {
ids.extend(row.as_chunks::<4>().0.iter().take(w as usize).map(|c| u32::from_le_bytes(*c)));
}
done.push(PickTile { key: p.key, origin: p.origin, size: p.size, ids });
}
p.buffer.unmap();
false
}
FAILED => false,
_ => true,
});
if !done.is_empty()
&& let Ok(mut o) = out.lock()
{
for t in done {
if self.drawn == Some(t.key) && t.ids.len() == t.size[0] as usize * t.size[1] as usize {
o.tiles.retain(|x| x.key == t.key);
o.tiles.push(t);
}
}
let excess = o.tiles.len().saturating_sub(KEEP);
o.tiles.drain(..excess);
}
}
}
fn uniform_bytes(v: &PickView) -> Vec<u8> {
let mut b = Vec::with_capacity(UNIFORM_SIZE as usize);
for col in v.view_proj {
for x in col {
b.extend_from_slice(&x.to_le_bytes());
}
}
for x in v.eye.into_iter().chain(v.back).chain([v.lift]) {
b.extend_from_slice(&x.to_le_bytes());
}
b
}
#[cfg(test)]
#[path = "pick_tests.rs"]
mod tests;
+79
View File
@@ -0,0 +1,79 @@
use super::*;
/// Looking down −z at x, y in −10..10 (orthographic): depth 0 at z = 50, 1 at z = −50.
const VIEW: [[f32; 4]; 4] = [[0.1, 0.0, 0.0, 0.0], [0.0, 0.1, 0.0, 0.0], [0.0, 0.0, -0.01, 0.0], [0.0, 0.0, 0.5, 1.0]];
const SIZE: u32 = 64;
/// The pixel of model point (x, y).
fn px(x: f32, y: f32) -> [i64; 2] {
[((x / 10.0 + 1.0) * 0.5 * SIZE as f32) as i64, ((1.0 - y / 10.0) * 0.5 * SIZE as f32) as i64]
}
/// A square face at z = 0 (x, y in −5..5), and three edges: 1 on it, 2 well behind it, 3 behind
/// it by less than the lift.
fn scene() -> PickScene {
let mut s = PickScene::default();
let (a, b, c, d) = ([-5.0, -5.0, 0.0], [5.0, -5.0, 0.0], [5.0, 5.0, 0.0], [-5.0, 5.0, 0.0]);
s.triangle([a, b, c]);
s.triangle([a, c, d]);
s.segment([-8.0, 0.0, 0.0], [8.0, 0.0, 0.0], 1);
s.segment([2.0, -8.0, -3.0], [2.0, 8.0, -3.0], 2);
s.segment([-2.0, -8.0, -0.2], [-2.0, 8.0, -0.2], 3);
s
}
#[test]
fn the_pick_buffer_names_the_edges_seen_at_each_pixel() {
let (_, device, queue) = match crate::open_headless() {
Ok(d) => d,
Err(e) => {
assert!(std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "a GPU is required here: {e}");
eprintln!("skipping: {e}");
return;
}
};
let mut pb = PickBuffer::new(&device);
let out = PickOut::default();
let view = PickView { key: 7, view_proj: VIEW, eye: [0.0, 0.0, 100.0, 0.0], back: [0.0, 0.0, 1.0], lift: 0.5, size: [SIZE, SIZE] };
let scene = scene();
let all = [0, 0, SIZE as i64 - 1, SIZE as i64 - 1];
for _ in 0..8 {
let mut encoder = device.create_command_encoder(&Default::default());
pb.prepare(&device, &queue, &mut encoder, 1, &scene, &view, Some([SIZE / 2, SIZE / 2]), &out);
queue.submit([encoder.finish()]);
let _ = device.poll(wgpu::PollType::wait_indefinitely());
if out.lock().unwrap().tile(7, [SIZE, SIZE], all).is_some() {
break;
}
}
let results = out.lock().unwrap();
let tile = results.tile(7, [SIZE, SIZE], all).expect("the tile is read back within a few frames");
let at = |x: f32, y: f32| {
let [px, py] = px(x, y);
tile.ids_in([px - 1, py - 1, px + 1, py + 1])
};
assert_eq!(at(0.0, 0.0), vec![1], "the edge on the face");
assert_eq!(at(2.0, 3.0), Vec::<u32>::new(), "the edge behind the face is hidden");
assert_eq!(at(2.0, 7.5), vec![2], "and seen where the face does not cover it");
assert_eq!(at(-2.0, 3.0), vec![3], "an edge behind the face by less than the lift counts as seen");
assert_eq!(at(3.5, 3.5), Vec::<u32>::new(), "the face itself names no edge");
assert!(results.tile(8, [SIZE, SIZE], all).is_none(), "another view's tile does not answer");
drop(results);
assert!(!pb.waiting());
}
#[test]
fn tiles_cover_the_window_around_the_cursor_inside_the_view() {
assert_eq!(tile_around([10, 10], [1000, 800]), ([0, 0], [TILE, TILE]));
assert_eq!(tile_around([500, 400], [1000, 800]), ([500 - TILE / 2, 400 - TILE / 2], [TILE, TILE]));
assert_eq!(tile_around([999, 799], [1000, 800]), ([1000 - TILE, 800 - TILE], [TILE, TILE]));
assert_eq!(tile_around([30, 20], [50, 40]), ([0, 0], [50, 40]));
let t = PickTile { key: 1, origin: [100, 100], size: [TILE, TILE], ids: vec![0; (TILE * TILE) as usize] };
assert!(t.covers(1, [1000, 800], [110, 110, 120, 120]));
assert!(!t.covers(1, [1000, 800], [90, 110, 120, 120]));
assert!(!t.covers(2, [1000, 800], [110, 110, 120, 120]));
// A window past the view's border needs only the pixels inside it.
let corner = PickTile { key: 1, origin: [0, 0], size: [TILE, TILE], ids: vec![5; (TILE * TILE) as usize] };
assert!(corner.covers(1, [1000, 800], [-8, -8, 8, 8]));
assert_eq!(corner.ids_in([-8, -8, 8, 8]), vec![5]);
}