From 409a746b55439ddaa5156f6e98a792b34161c88e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Fri, 4 Sep 2026 18:08:59 +0200 Subject: [PATCH 1/7] feat: add a debug renderer for the rbd scene --- crates/nexus_viewer2d/Cargo.toml | 2 +- crates/nexus_viewer3d/Cargo.toml | 2 +- src_rbd/pipeline/mod.rs | 4 +- src_rbd/pipeline/rbd_state.rs | 76 ++++++++- src_viewer/debug_render/backend.rs | 61 +++++++ src_viewer/debug_render/mod.rs | 7 + src_viewer/debug_render/renderer.rs | 243 ++++++++++++++++++++++++++++ src_viewer/lib.rs | 2 + src_viewer/ui.rs | 89 ++++++++++ src_viewer/viewer.rs | 41 +++++ 10 files changed, 523 insertions(+), 4 deletions(-) create mode 100644 src_viewer/debug_render/backend.rs create mode 100644 src_viewer/debug_render/mod.rs create mode 100644 src_viewer/debug_render/renderer.rs diff --git a/crates/nexus_viewer2d/Cargo.toml b/crates/nexus_viewer2d/Cargo.toml index 8ca75a9..4f86ae3 100644 --- a/crates/nexus_viewer2d/Cargo.toml +++ b/crates/nexus_viewer2d/Cargo.toml @@ -34,7 +34,7 @@ nexus2d = { workspace = true, features = ["rbd", "mpm"]} glamx = { workspace = true } khal = { workspace = true } vortx = { workspace = true } -rapier2d = { workspace = true, features = ["default"] } +rapier2d = { workspace = true, features = ["default", "debug-render"] } bytemuck = { workspace = true } kiss3d = { workspace = true, features = ["egui"] } web-time = { workspace = true } diff --git a/crates/nexus_viewer3d/Cargo.toml b/crates/nexus_viewer3d/Cargo.toml index 71886a9..079847a 100644 --- a/crates/nexus_viewer3d/Cargo.toml +++ b/crates/nexus_viewer3d/Cargo.toml @@ -34,7 +34,7 @@ nexus3d = { workspace = true, features = ["rbd", "mpm"]} glamx = { workspace = true } khal = { workspace = true } vortx = { workspace = true } -rapier3d = { workspace = true, features = ["default"] } +rapier3d = { workspace = true, features = ["default", "debug-render"] } bytemuck = { workspace = true } kiss3d = { workspace = true, features = ["egui", "rt_switcher"] } web-time = { workspace = true } diff --git a/src_rbd/pipeline/mod.rs b/src_rbd/pipeline/mod.rs index b62731b..7195a78 100644 --- a/src_rbd/pipeline/mod.rs +++ b/src_rbd/pipeline/mod.rs @@ -18,5 +18,7 @@ mod test_polyline_warmstart; #[cfg(feature = "dim3")] pub use rbd_state::RbdSnapshot; -pub use rbd_state::{RbdCapacities, RbdGraphKey, RbdResizePolicy, RbdState, RunStats}; +pub use rbd_state::{ + DebugContact, RbdCapacities, RbdGraphKey, RbdResizePolicy, RbdState, RunStats, +}; pub use rbd_step::RbdPipeline; diff --git a/src_rbd/pipeline/rbd_state.rs b/src_rbd/pipeline/rbd_state.rs index 40443b2..c77622a 100644 --- a/src_rbd/pipeline/rbd_state.rs +++ b/src_rbd/pipeline/rbd_state.rs @@ -4,8 +4,9 @@ use crate::broad_phase::{LbvhState, PfmSortState}; use crate::dynamics::GpuImpulseJointSet; #[cfg(feature = "dim3")] use crate::dynamics::GpuMultibodySet; -use crate::math::Pose; +use crate::math::{Pose, Vector}; use crate::queries::{GpuColliderMaterial, GpuIndexedContact}; +use crate::shaders::queries::MAX_MANIFOLD_POINTS; use crate::shaders::PaddedVector; use crate::shaders::broad_phase::{CollisionPair, ContactPlan, NarrowPhasePfmPair}; #[cfg(feature = "dim3")] @@ -24,6 +25,35 @@ use khal::backend::{Backend, GpuBackend, GpuReadback}; use std::time::Duration; use vortx::tensor::Tensor; +/// One world-space contact point, read back by [`RbdState::debug_contacts`]. +#[derive(Copy, Clone, Debug, Default, PartialEq)] +pub struct DebugContact { + /// Contact point on the first collider, in world space. + pub point: Vector, + /// World-space contact normal, pointing away from the first collider. + pub normal: Vector, + /// Signed distance along the normal (negative if penetrating). + pub dist: f32, + /// Batch (environment) the contact belongs to. + pub batch: u32, + /// Env-local indices of the two colliders. + pub colliders: [u32; 2], + /// Env-local indices of the parent rigid-bodies of the two colliders. + pub bodies: [u32; 2], +} + +impl DebugContact { + /// The contact point on the second collider, in world space. + pub fn point_b(&self) -> Vector { + self.point + self.normal * self.dist + } + + /// The point the contact constraint acts on: the middle of the two contact points. + pub fn solver_point(&self) -> Vector { + self.point + self.normal * (self.dist * 0.5) + } +} + /// Performance statistics collected during a physics simulation step. #[derive(Default, Clone, Debug)] pub struct RunStats { @@ -555,6 +585,50 @@ impl RbdState { &self.new_constraints } + /// Debug: reads the contacts back from the GPU, as world-space points of all batches. + /// Slow: copies the whole contact and collider-pose buffers to the CPU. + pub async fn debug_contacts(&self, backend: &GpuBackend) -> Vec { + let Ok(manifolds) = backend + .slow_read_vec::(self.contacts.buffer()) + .await + else { + return Vec::new(); + }; + let Ok(poses) = backend + .slow_read_vec::(self.collider_world_poses.buffer()) + .await + else { + return Vec::new(); + }; + + // All slots of the flat contact buffer are written each frame (`len == 0` if empty). + // Collider and body ids are global: `global = local * num_batches + batch`. + let nb = self.num_batches.max(1); + let mut result = Vec::new(); + + for manifold in &manifolds { + let collider_a = manifold.colliders.x; + let Some(pose_a) = poses.get(collider_a as usize) else { + continue; + }; + let normal = pose_a.transform_vector(manifold.contact.normal_a); + + for k in 0..(manifold.contact.len as usize).min(MAX_MANIFOLD_POINTS) { + let point = manifold.contact.points_a[k]; + result.push(DebugContact { + point: pose_a.transform_point(point.pt), + normal, + dist: point.dist, + batch: collider_a % nb, + colliders: [manifold.colliders.x / nb, manifold.colliders.y / nb], + bodies: [manifold.bodies.x / nb, manifold.bodies.y / nb], + }); + } + } + + result + } + /// Debug: read back active contacts as `(collider_a, collider_b, body_a, /// body_b, manifold_len)` tuples (only `len > 0` entries). pub fn debug_contact_pairs(&self, backend: &GpuBackend) -> Vec<(u32, u32, u32, u32, u32)> { diff --git a/src_viewer/debug_render/backend.rs b/src_viewer/debug_render/backend.rs new file mode 100644 index 0000000..54474b6 --- /dev/null +++ b/src_viewer/debug_render/backend.rs @@ -0,0 +1,61 @@ +//! The kiss3d side of rapier's debug-render pipeline. + +use crate::rapier::pipeline::{DebugColor, DebugRenderBackend, DebugRenderObject}; +use nexus::rbd::math::Vector; + +/// A world-space segment to draw, with its RGBA color. +#[derive(Copy, Clone, Debug)] +pub struct DebugLine { + pub a: Vector, + pub b: Vector, + pub color: [f32; 4], +} + +/// A world-space point to draw, with its RGBA color. +#[derive(Copy, Clone, Debug)] +pub struct DebugPoint { + pub point: Vector, + pub color: [f32; 4], +} + +/// Collects the segments from rapier's `DebugRenderPipeline`. +/// rapier gives HSLA colors, so they are converted to RGBA here. +#[derive(Default)] +pub struct LineCollector { + pub lines: Vec, +} + +impl LineCollector { + /// Adds a segment with an RGBA color, for what nexus draws itself (e.g. the contacts). + pub fn push_rgba(&mut self, a: Vector, b: Vector, color: [f32; 4]) { + self.lines.push(DebugLine { a, b, color }); + } +} + +impl DebugRenderBackend for LineCollector { + fn draw_line(&mut self, _: DebugRenderObject, a: Vector, b: Vector, color: DebugColor) { + self.lines.push(DebugLine { + a, + b, + color: hsla_to_rgba(color), + }); + } +} + +/// Converts a rapier `[hue, saturation, lightness, alpha]` color to RGBA. +pub fn hsla_to_rgba([h, s, l, a]: DebugColor) -> [f32; 4] { + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + // `h / 60` picks the sextant; `x` is the ramp within it. + let h6 = (h / 60.0).rem_euclid(6.0); + let x = c * (1.0 - (h6 % 2.0 - 1.0).abs()); + let (r, g, b) = match h6 as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = l - c / 2.0; + [r + m, g + m, b + m, a] +} diff --git a/src_viewer/debug_render/mod.rs b/src_viewer/debug_render/mod.rs new file mode 100644 index 0000000..327a5fc --- /dev/null +++ b/src_viewer/debug_render/mod.rs @@ -0,0 +1,7 @@ +//! Wireframe debug rendering of the rigid-body scene. + +mod backend; +mod renderer; + +pub use backend::{DebugLine, DebugPoint, LineCollector, hsla_to_rgba}; +pub use renderer::{DebugRenderSettings, DebugRenderer}; diff --git a/src_viewer/debug_render/renderer.rs b/src_viewer/debug_render/renderer.rs new file mode 100644 index 0000000..37d38f1 --- /dev/null +++ b/src_viewer/debug_render/renderer.rs @@ -0,0 +1,243 @@ +//! The debug renderer: turns the GPU state into debug segments. + +use super::backend::{DebugLine, DebugPoint, LineCollector, hsla_to_rgba}; +use crate::rapier::pipeline::{DebugRenderMode, DebugRenderPipeline, DebugRenderStyle}; +use crate::rapier::prelude::{ColliderSet, RigidBodyHandle, RigidBodySet}; +use khal::backend::{Backend, GpuBackend}; +use nexus::rbd::math::Pose; +use nexus::state::NexusState; + +/// Debug-renderer settings, edited in the viewer UI. +#[derive(Clone, Debug, PartialEq)] +pub struct DebugRenderSettings { + /// Main switch. When off, nothing is drawn or read back. + pub enabled: bool, + /// What to draw (see [`DebugRenderMode`]). + pub mode: DebugRenderMode, + /// Width of the debug segments, in pixels. + pub line_width: f32, + /// Size of the debug points, in pixels. + pub point_size: f32, + /// World-space length of the contact normals. + pub contact_normal_length: f32, + /// World-space length of the rigid-body axes. + pub rigid_body_axes_length: f32, +} + +impl Default for DebugRenderSettings { + fn default() -> Self { + let style = DebugRenderStyle::default(); + Self { + enabled: false, + // Contacts are on by default (this is the main reason for a GPU debug renderer). + // Collider shapes are off since the viewer already draws them. + mode: DebugRenderMode::CONTACTS + | DebugRenderMode::JOINTS + | DebugRenderMode::RIGID_BODY_AXES, + line_width: 2.0, + point_size: 6.0, + contact_normal_length: style.contact_normal_length, + rigid_body_axes_length: style.rigid_body_axes_length, + } + } +} + +/// A copy of the rapier sets of one environment, with poses updated from the GPU. +struct WorldMirror { + bodies: RigidBodySet, + colliders: ColliderSet, +} + +/// Draws the scene as wireframes and contact markers. +#[derive(Default)] +pub struct DebugRenderer { + pipeline: DebugRenderPipeline, + mirrors: Vec, + /// `(bodies, colliders)` counts of each environment when the mirrors were made. + /// The mirrors are rebuilt when they change (bodies added or removed). + mirrored_counts: Vec<(usize, usize)>, + lines: Vec, + points: Vec, +} + +impl DebugRenderer { + /// The segments to draw this frame. + pub fn lines(&self) -> &[DebugLine] { + &self.lines + } + + /// The points to draw this frame. + pub fn points(&self) -> &[DebugPoint] { + &self.points + } + + /// Drops the pose mirrors (e.g. when switching demo). They are rebuilt by the next sync. + pub fn clear_scene(&mut self) { + self.mirrors.clear(); + self.mirrored_counts.clear(); + self.lines.clear(); + self.points.clear(); + } + + /// Rebuilds the debug geometry from the GPU state. + /// Reads data back from the GPU, so only call it when enabled. + pub async fn sync( + &mut self, + state: &NexusState, + backend: &GpuBackend, + settings: &DebugRenderSettings, + ) { + self.lines.clear(); + self.points.clear(); + + if !settings.enabled || settings.mode.is_empty() { + return; + } + let Some(rbd) = state.rbd.as_ref() else { + return; + }; + + self.pipeline.mode = settings.mode; + self.pipeline.style.contact_normal_length = settings.contact_normal_length; + self.pipeline.style.rigid_body_axes_length = settings.rigid_body_axes_length; + + // Only the wireframes need the poses, so skip the readback for contacts alone. + let wireframe = DebugRenderMode::COLLIDER_SHAPES + | DebugRenderMode::COLLIDER_AABBS + | DebugRenderMode::RIGID_BODY_AXES + | DebugRenderMode::JOINTS; + if settings.mode.intersects(wireframe) { + let poses = backend + .slow_read_vec::(rbd.body_poses().buffer()) + .await + .unwrap_or_default(); + self.sync_mirrors(state, &poses); + self.render_wireframes(state); + } + + let contact_modes = DebugRenderMode::CONTACTS | DebugRenderMode::SOLVER_CONTACTS; + if settings.mode.intersects(contact_modes) { + self.render_contacts(rbd, backend, settings).await; + } + } + + /// Rebuilds the pose mirrors if the scene changed, then updates their poses. + fn sync_mirrors(&mut self, state: &NexusState, poses: &[Pose]) { + let counts: Vec<(usize, usize)> = (0..state.num_environments()) + .map(|env| { + let world = state.rbd_world(env); + (world.bodies.len(), world.colliders.len()) + }) + .collect(); + + if counts != self.mirrored_counts { + self.mirrors = (0..counts.len()) + .map(|env| { + let world = state.rbd_world(env); + WorldMirror { + bodies: world.bodies.clone(), + colliders: world.colliders.clone(), + } + }) + .collect(); + self.mirrored_counts = counts; + } + + for (env, mirror) in self.mirrors.iter_mut().enumerate() { + let Some(map) = state.rbd2gpu.get(env) else { + continue; + }; + let pose_of = |handle: RigidBodyHandle| { + map.get(handle.0) + .map(|r| r.gpu_id) + .filter(|id| *id != u32::MAX) + .and_then(|id| poses.get(id as usize)) + .copied() + }; + + for (handle, rb) in mirror.bodies.iter_mut() { + if let Some(pose) = pose_of(handle) { + rb.set_position(pose, false); + } + } + // Collider poses are computed from the body poses (their offset is fixed), + // which saves a readback. + for (_, co) in mirror.colliders.iter_mut() { + let Some(parent) = co.parent() else { continue }; + let local = co.position_wrt_parent().copied().unwrap_or_default(); + if let Some(pose) = pose_of(parent) { + co.set_position(pose * local); + } + } + } + } + + /// Runs rapier's debug-render passes on the pose mirrors. + fn render_wireframes(&mut self, state: &NexusState) { + let mut collector = LineCollector::default(); + + for (env, mirror) in self.mirrors.iter().enumerate() { + self.pipeline + .render_rigid_bodies(&mut collector, &mirror.bodies); + self.pipeline + .render_colliders(&mut collector, &mirror.bodies, &mirror.colliders); + // Joints only need the body poses, so the joint sets of the state are used directly. + let world = state.rbd_world(env); + self.pipeline.render_joints( + &mut collector, + &mirror.bodies, + &world.impulse_joints, + &world.multibody_joints, + ); + } + + self.lines.append(&mut collector.lines); + } + + /// Draws the GPU contacts like rapier's `render_contacts`: a segment between + /// the two contact points, and the normal. + async fn render_contacts( + &mut self, + rbd: &nexus::rbd::pipeline::RbdState, + backend: &GpuBackend, + settings: &DebugRenderSettings, + ) { + let contacts = rbd.debug_contacts(backend).await; + let depth_color = hsla_to_rgba(self.pipeline.style.contact_depth_color); + let normal_color = hsla_to_rgba(self.pipeline.style.contact_normal_color); + let normal_len = settings.contact_normal_length; + + for contact in &contacts { + if settings.mode.contains(DebugRenderMode::CONTACTS) { + self.lines.push(DebugLine { + a: contact.point, + b: contact.point_b(), + color: depth_color, + }); + self.lines.push(DebugLine { + a: contact.point, + b: contact.point + contact.normal * normal_len, + color: normal_color, + }); + self.points.push(DebugPoint { + point: contact.point, + color: depth_color, + }); + } + + if settings.mode.contains(DebugRenderMode::SOLVER_CONTACTS) { + // The constraint acts on the middle of the two contact points. + let point = contact.solver_point(); + self.lines.push(DebugLine { + a: point, + b: point + contact.normal * normal_len, + color: normal_color, + }); + self.points.push(DebugPoint { + point, + color: normal_color, + }); + } + } + } +} diff --git a/src_viewer/lib.rs b/src_viewer/lib.rs index 780f1bc..98db40f 100644 --- a/src_viewer/lib.rs +++ b/src_viewer/lib.rs @@ -11,6 +11,7 @@ pub extern crate rapier2d as rapier; pub extern crate rapier3d as rapier; mod backend; +pub mod debug_render; mod graphics; #[cfg(feature = "dim3")] pub mod sensors; @@ -18,6 +19,7 @@ mod ui; pub mod viewer; pub use backend::BackendType; +pub use debug_render::{DebugRenderSettings, DebugRenderer}; #[cfg(feature = "dim3")] pub use graphics::{RenderMaterial, VisualTexture}; #[cfg(feature = "dim3")] diff --git a/src_viewer/ui.rs b/src_viewer/ui.rs index 76c6d95..cb8804b 100644 --- a/src_viewer/ui.rs +++ b/src_viewer/ui.rs @@ -6,6 +6,8 @@ use nexus::state::NexusCounts; use std::time::Duration; use crate::backend::BackendType; +use crate::debug_render::DebugRenderSettings; +use crate::rapier::pipeline::DebugRenderMode; use egui::{Button, CollapsingHeader, Color32, ComboBox, CornerRadius, RichText, Stroke}; /// Sets up a custom warm theme that complements the app's off-white background. @@ -224,7 +226,94 @@ fn simulation_settings(ui: &mut egui::Ui, state: &mut UiState) { } ui.label("Rigid bodies"); ui.add(egui::Slider::new(&mut s.rbd_steps_per_frame, 1..=20).text("steps / frame")); + + ui.add_space(4.0); + debug_render_settings(ui, &mut state.debug_render); + } +} + +/// Debug-renderer controls: what to draw, and how. +fn debug_render_settings(ui: &mut egui::Ui, s: &mut DebugRenderSettings) { + ui.checkbox(&mut s.enabled, "Debug render").on_hover_text( + "Wireframe overlay of the physics state. Reads the scene back from the \ + GPU every frame, so it slows the viewer down.", + ); + if !s.enabled { + return; } + + CollapsingHeader::new("Debug render options") + .id_salt("debug_render_options") + .default_open(true) + .show(ui, |ui| { + let mut flag = |ui: &mut egui::Ui, mode: DebugRenderMode, label: &str, hover: &str| { + let mut on = s.mode.contains(mode); + if ui.checkbox(&mut on, label).on_hover_text(hover).changed() { + s.mode.set(mode, on); + } + }; + + flag( + ui, + DebugRenderMode::COLLIDER_SHAPES, + "Collider shapes", + "Wireframe outline of every collider, colored by body type", + ); + flag( + ui, + DebugRenderMode::COLLIDER_AABBS, + "Collider AABBs", + "World-space bounding box of every collider", + ); + flag( + ui, + DebugRenderMode::RIGID_BODY_AXES, + "Rigid-body axes", + "Local coordinate axes at each body's center of mass", + ); + flag( + ui, + DebugRenderMode::IMPULSE_JOINTS, + "Impulse joints", + "Anchor and separation segments of each impulse joint", + ); + flag( + ui, + DebugRenderMode::MULTIBODY_JOINTS, + "Multibody joints", + "Anchor and separation segments of each multibody joint", + ); + flag( + ui, + DebugRenderMode::CONTACTS, + "Contacts", + "Geometric contacts: the segment joining the two contact points, \ + plus the contact normal", + ); + flag( + ui, + DebugRenderMode::SOLVER_CONTACTS, + "Solver contacts", + "The points the contact constraints actually act on (the midpoint \ + of each contact pair)", + ); + + ui.add_space(4.0); + ui.add(egui::Slider::new(&mut s.line_width, 0.5..=10.0).text("line width")) + .on_hover_text("Width of the debug segments, in pixels"); + ui.add(egui::Slider::new(&mut s.point_size, 1.0..=20.0).text("point size")) + .on_hover_text("Size of the contact points, in pixels"); + ui.add( + egui::Slider::new(&mut s.contact_normal_length, 0.01..=10.0) + .logarithmic(true) + .text("normal length"), + ); + ui.add( + egui::Slider::new(&mut s.rigid_body_axes_length, 0.01..=10.0) + .logarithmic(true) + .text("axes length"), + ); + }); } /// A labelled per-component drag editor for a gravity vector (2D or 3D). diff --git a/src_viewer/viewer.rs b/src_viewer/viewer.rs index 5e86382..ae7242e 100644 --- a/src_viewer/viewer.rs +++ b/src_viewer/viewer.rs @@ -53,6 +53,7 @@ use nexus::state::{NexusCounts, NexusState}; use rapier::prelude::{RigidBodyHandle, SharedShape}; use crate::backend::BackendType; +use crate::debug_render::{DebugRenderSettings, DebugRenderer}; use crate::graphics::RenderContext; #[cfg(feature = "dim3")] use crate::graphics::VisualTexture; @@ -131,6 +132,9 @@ pub struct UiState { /// Per-particle coloring mode for MPM rendering (view-only; not a sim /// setting). Drives the `WgPrepReadback` render config in `sync`. pub mpm_render_mode: MpmRenderMode, + /// Debug rendering of the physics state. Off by default: it reads the + /// state back from the GPU every frame. + pub debug_render: DebugRenderSettings, } /// Editable simulation settings exposed in the viewer UI. The viewer pulls @@ -278,6 +282,8 @@ pub struct NexusViewer { /// Empty on the zero-readback path. #[cfg(feature = "dim3")] body_pose_cache: Vec, + /// Debug geometry, rebuilt by [`Self::sync`] and drawn by [`Self::render_frame`]. + debug_renderer: DebugRenderer, pub ui: UiState, } @@ -404,6 +410,7 @@ impl NexusViewer { sensor_shadow_resolution: (DEFAULT_SHADOW_RESOLUTION, DEFAULT_SHADOW_ATLAS_LAYERS), #[cfg(feature = "dim3")] body_pose_cache: Vec::new(), + debug_renderer: DebugRenderer::default(), ui: UiState { run_state: RunState::Paused, run_stats: RunStats::default(), @@ -425,6 +432,7 @@ impl NexusViewer { has_rbd: false, counts: NexusCounts::default(), mpm_render_mode: MpmRenderMode::default(), + debug_render: DebugRenderSettings::default(), }, }; @@ -1377,6 +1385,11 @@ impl NexusViewer { self.sync_with_readback(state).await?; } + let debug_settings = self.ui.debug_render.clone(); + let debug_backend = self.backend().clone(); + self.debug_renderer + .sync(state, &debug_backend, &debug_settings) + .await; self.sync_timestamps(timestamps).await; // `pipeline.step` overwrites `run_stats` (with empty pass timings) every @@ -1394,6 +1407,30 @@ impl NexusViewer { Ok(()) } + /// Adds the debug geometry to the point/line renderers of this frame. + /// They are drawn and cleared by the next `window.render`. + fn draw_debug_render(&mut self) { + let line_width = self.ui.debug_render.line_width; + let point_size = self.ui.debug_render.point_size; + + for line in self.debug_renderer.lines() { + let color = Color::new(line.color[0], line.color[1], line.color[2], line.color[3]); + #[cfg(feature = "dim3")] + self.window + .draw_line(line.a, line.b, color, line_width, false); + #[cfg(feature = "dim2")] + self.window.draw_line_2d(line.a, line.b, color, line_width); + } + + for pt in self.debug_renderer.points() { + let color = Color::new(pt.color[0], pt.color[1], pt.color[2], pt.color[3]); + #[cfg(feature = "dim3")] + self.window.draw_point(pt.point, color, point_size); + #[cfg(feature = "dim2")] + self.window.draw_point_2d(pt.point, color, point_size); + } + } + /// Creates a unit point-cloud base node (a cube in 3D, a rectangle in 2D) /// that subsequent per-particle/per-vertex instances are drawn from. fn new_point_node(&mut self) -> SceneNodeX { @@ -1467,6 +1504,7 @@ impl NexusViewer { self.scene2d = SceneNode2d::empty(); self.nexus_render.clear(); self.mpm_node = None; + self.debug_renderer.clear_scene(); } /// Whether the simulation should advance this frame, honoring the @@ -1491,6 +1529,9 @@ impl NexusViewer { /// selected). This is the no-scene-argument counterpart of the legacy /// scene-argument `render`. pub async fn render_frame(&mut self) -> bool { + // Added before `render`, which draws then clears the point/line renderers. + self.draw_debug_render(); + let cont = self .window .render( From 9b91391a6845a03812e61946a0cbf5df379994c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Thu, 10 Sep 2026 14:56:47 +0200 Subject: [PATCH 2/7] feat: add debug rnederer for the mpm scenes --- crates/nexus_viewer2d/Cargo.toml | 1 + crates/nexus_viewer3d/Cargo.toml | 1 + src_mpm/debug.rs | 231 ++++++++++++++++++++++++++++ src_mpm/grid/grid.rs | 14 +- src_mpm/lib.rs | 1 + src_viewer/debug_render/mod.rs | 4 +- src_viewer/debug_render/mpm.rs | 178 +++++++++++++++++++++ src_viewer/debug_render/renderer.rs | 95 +++++++++--- src_viewer/lib.rs | 2 +- src_viewer/ui.rs | 204 ++++++++++++++++-------- 10 files changed, 636 insertions(+), 95 deletions(-) create mode 100644 src_mpm/debug.rs create mode 100644 src_viewer/debug_render/mpm.rs diff --git a/crates/nexus_viewer2d/Cargo.toml b/crates/nexus_viewer2d/Cargo.toml index 4f86ae3..4e04af9 100644 --- a/crates/nexus_viewer2d/Cargo.toml +++ b/crates/nexus_viewer2d/Cargo.toml @@ -35,6 +35,7 @@ glamx = { workspace = true } khal = { workspace = true } vortx = { workspace = true } rapier2d = { workspace = true, features = ["default", "debug-render"] } +bitflags = { workspace = true } bytemuck = { workspace = true } kiss3d = { workspace = true, features = ["egui"] } web-time = { workspace = true } diff --git a/crates/nexus_viewer3d/Cargo.toml b/crates/nexus_viewer3d/Cargo.toml index 079847a..86b40f2 100644 --- a/crates/nexus_viewer3d/Cargo.toml +++ b/crates/nexus_viewer3d/Cargo.toml @@ -35,6 +35,7 @@ glamx = { workspace = true } khal = { workspace = true } vortx = { workspace = true } rapier3d = { workspace = true, features = ["default", "debug-render"] } +bitflags = { workspace = true } bytemuck = { workspace = true } kiss3d = { workspace = true, features = ["egui", "rt_switcher"] } web-time = { workspace = true } diff --git a/src_mpm/debug.rs b/src_mpm/debug.rs new file mode 100644 index 0000000..4c275c8 --- /dev/null +++ b/src_mpm/debug.rs @@ -0,0 +1,231 @@ +//! Debug readback of the MPM state, for visualization and tests. + +use crate::mpm_shaders::IVector; +use crate::mpm_shaders::grid::grid::{ActiveBlockHeader, Grid, Node}; +use crate::mpm_shaders::solver::particle::{Kinematics, ParticleProperties, Position}; +use crate::pipeline::MpmState; +use khal::backend::{Backend, GpuBackend}; +use nexus_rbd::math::Vector; +use vortx::tensor::Tensor; + +/// Nodes along each axis of a grid block (8x8 in 2D, 4x4x4 in 3D). +#[cfg(feature = "dim2")] +pub const BLOCK_SIZE: u32 = 8; +/// Nodes along each axis of a grid block. +#[cfg(feature = "dim3")] +pub const BLOCK_SIZE: u32 = 4; + +/// Nodes per block, i.e. the stride between two blocks in the node buffer. +const NUM_CELL_PER_BLOCK: u32 = BLOCK_SIZE.pow(crate::mpm_shaders::DIM); + +/// One MPM particle read back by [`MpmState::debug_particles`]. +#[derive(Copy, Clone, Debug, Default, PartialEq)] +pub struct DebugParticle { + /// World-space position. + pub position: Vector, + /// World-space velocity. + pub velocity: Vector, + /// The particle's mass. + pub mass: f32, + /// The particle's initial radius. + pub radius: f32, + /// Render group the particle belongs to (carries no physics). + pub group_id: u32, + /// Whether the particle takes part in the transfers at all. + pub enabled: bool, + /// Whether the particle is pinned in place. + pub fixed: bool, + /// Outward normal of the closest collider surface. + /// Only set with CPIC, and zero when no collider is near. + pub cdf_normal: Vector, + /// Signed distance to that surface (negative inside the collider). + pub cdf_distance: f32, + /// CPIC affinity bits: one (affinity, sign) pair per nearby collider. + pub cdf_affinity: u32, +} + +/// One allocated block of the sparse grid, read back by [`MpmState::debug_grid`]. +#[derive(Copy, Clone, Debug, Default, PartialEq)] +pub struct DebugGridBlock { + /// The block coordinate in the virtual grid. + pub virtual_id: IVector, + /// World-space corner of the first node of the block. + pub mins: Vector, + /// The opposite corner, one cell after the last node of the block. + pub maxs: Vector, + /// Number of particles whose main block is this one. + pub num_particles: u32, + /// Number of particles touching the block, including the ones from a neighbor block. + /// If `num_particles` is zero, the block exists only because of these. + pub num_particles_with_extras: u32, +} + +/// One grid node with mass, read back by [`MpmState::debug_grid`]. +#[derive(Copy, Clone, Debug, Default, PartialEq)] +pub struct DebugGridNode { + /// World-space position of the node. + pub position: Vector, + /// Velocity after the last grid update. + pub velocity: Vector, + /// Mass given to the node by the last P2G. + pub mass: f32, + /// Signed distance to the closest collider surface, or a huge value if none is near. + pub cdf_distance: f32, + /// CPIC affinity bits of the node. + pub cdf_affinity: u32, +} + +/// The sparse grid: its allocated blocks and the nodes with mass. +#[derive(Clone, Debug, Default)] +pub struct DebugGrid { + /// Distance between two neighbor nodes. + pub cell_width: f32, + /// All the blocks allocated by the last sort. + pub blocks: Vec, + /// The nodes of these blocks that have mass. Empty nodes are skipped. + pub nodes: Vec, +} + +impl MpmState { + /// Debug: reads the particles back from the GPU, in world space. + /// Slow: copies all the particle buffers to the CPU. + pub async fn debug_particles(&self, backend: &GpuBackend) -> Vec { + let len = self.particles.len(); + let positions = read_prefix::(backend, &self.particles.positions, len).await; + let kinematics = read_prefix::(backend, &self.particles.kinematics, len).await; + let properties = + read_prefix::(backend, &self.particles.properties, len).await; + + if positions.len() < len || kinematics.len() < len || properties.len() < len { + return Vec::new(); + } + + (0..len) + .map(|i| { + let kin = &kinematics[i]; + let props = &properties[i]; + DebugParticle { + position: positions[i].pt, + velocity: kin.velocity, + mass: kin.mass, + radius: props.init_radius, + group_id: props.group_id, + enabled: kin.enabled != 0, + fixed: props.fixed != 0, + cdf_normal: kin.cdf.normal, + cdf_distance: kin.cdf.signed_distance, + cdf_affinity: kin.cdf.affinity.0, + } + }) + .collect() + } + + /// Debug: reads the sparse grid back from the GPU, in world space. + /// Only the blocks allocated by the last sort are converted. Slow. + pub async fn debug_grid(&self, backend: &GpuBackend) -> DebugGrid { + // `MpmPipeline::step` swaps the buffers before sorting, so `grid.meta` is the last one. + let Ok(meta) = backend.slow_read_vec::(self.grid.meta.buffer()).await else { + return DebugGrid::default(); + }; + let Some(meta) = meta.first().copied() else { + return DebugGrid::default(); + }; + + let cell_width = meta.cell_width; + let num_blocks = meta.num_active_blocks.min(meta.capacity) as usize; + let headers = + read_prefix::(backend, &self.grid.active_blocks, num_blocks).await; + let nodes = read_prefix::( + backend, + &self.grid.nodes, + num_blocks * NUM_CELL_PER_BLOCK as usize, + ) + .await; + + let block_extent = Vector::splat(BLOCK_SIZE as f32 * cell_width); + let mut blocks = Vec::with_capacity(headers.len()); + let mut debug_nodes = Vec::new(); + + for (bid, header) in headers.iter().enumerate() { + let mins = block_origin(header.virtual_id.id, cell_width); + blocks.push(DebugGridBlock { + virtual_id: header.virtual_id.id, + mins, + maxs: mins + block_extent, + num_particles: header.num_particles, + num_particles_with_extras: header.num_particles_with_extras, + }); + + let first_node = bid * NUM_CELL_PER_BLOCK as usize; + for shift in 0..NUM_CELL_PER_BLOCK as usize { + let Some(node) = nodes.get(first_node + shift) else { + continue; + }; + if node.mass <= 0.0 { + continue; + } + debug_nodes.push(DebugGridNode { + position: mins + node_shift(shift as u32) * cell_width, + velocity: node.momentum_velocity, + mass: node.mass, + cdf_distance: node.cdf.distance, + cdf_affinity: node.cdf.affinities.0, + }); + } + } + + DebugGrid { + cell_width, + blocks, + nodes: debug_nodes, + } + } +} + +/// World-space position of the first node of a block (like `gpu_grid_update`). +fn block_origin(virtual_id: IVector, cell_width: f32) -> Vector { + #[cfg(feature = "dim2")] + let origin = Vector::new(virtual_id.x as f32, virtual_id.y as f32); + #[cfg(feature = "dim3")] + let origin = Vector::new( + virtual_id.x as f32, + virtual_id.y as f32, + virtual_id.z as f32, + ); + origin * (BLOCK_SIZE as f32 * cell_width) +} + +/// Offset, in cells, of a node from the first node of its block. +fn node_shift(shift_in_block: u32) -> Vector { + #[cfg(feature = "dim2")] + { + Vector::new( + (shift_in_block % BLOCK_SIZE) as f32, + (shift_in_block / BLOCK_SIZE) as f32, + ) + } + #[cfg(feature = "dim3")] + { + Vector::new( + (shift_in_block % BLOCK_SIZE) as f32, + ((shift_in_block / BLOCK_SIZE) % BLOCK_SIZE) as f32, + (shift_in_block / (BLOCK_SIZE * BLOCK_SIZE)) as f32, + ) + } +} + +/// Reads the first `len` elements of `tensor`, or an empty vector on failure. +async fn read_prefix( + backend: &GpuBackend, + tensor: &Tensor, + len: usize, +) -> Vec { + let mut out = vec![T::default(); len.min(tensor.len() as usize)]; + if out.is_empty() { + return out; + } + match backend.slow_read_buffer(tensor.buffer(), &mut out).await { + Ok(()) => out, + Err(_) => Vec::new(), + } +} diff --git a/src_mpm/grid/grid.rs b/src_mpm/grid/grid.rs index db88009..442c78c 100644 --- a/src_mpm/grid/grid.rs +++ b/src_mpm/grid/grid.rs @@ -513,9 +513,17 @@ impl GpuGrid { let default_entries = vec![default_entry; capacity as usize]; let prev_hmap_entries = Tensor::vector(backend, &default_entries, BufferUsages::STORAGE)?; let hmap_entries = Tensor::vector(backend, &default_entries, BufferUsages::STORAGE)?; - let nodes = - Tensor::vector_uninit(backend, capacity * NODES_PER_BLOCK, BufferUsages::STORAGE)?; - let active_blocks = Tensor::vector_uninit(backend, capacity, BufferUsages::STORAGE)?; + // COPY_SRC so the debug-renderer can read the nodes. + let nodes = Tensor::vector_uninit( + backend, + capacity * NODES_PER_BLOCK, + BufferUsages::STORAGE | BufferUsages::COPY_SRC, + )?; + let active_blocks = Tensor::vector_uninit( + backend, + capacity, + BufferUsages::STORAGE | BufferUsages::COPY_SRC, + )?; let scan_values = Tensor::vector_uninit(backend, capacity, BufferUsages::STORAGE)?; let active_blocks_snapshot = Tensor::vector(backend, [0u32], BufferUsages::STORAGE)?; let indirect_n_blocks_groups = diff --git a/src_mpm/lib.rs b/src_mpm/lib.rs index 7217c61..80778ad 100644 --- a/src_mpm/lib.rs +++ b/src_mpm/lib.rs @@ -24,6 +24,7 @@ use khal::re_exports::include_dir::{Dir, include_dir}; /// Embedded SPIR-V shader directory. pub static SPIRV_DIR: Dir<'static> = include_dir!("$OUT_DIR/shaders-spirv"); +pub mod debug; pub mod grid; pub mod models; pub mod pipeline; diff --git a/src_viewer/debug_render/mod.rs b/src_viewer/debug_render/mod.rs index 327a5fc..2b9fd01 100644 --- a/src_viewer/debug_render/mod.rs +++ b/src_viewer/debug_render/mod.rs @@ -1,7 +1,9 @@ -//! Wireframe debug rendering of the rigid-body scene. +//! Wireframe debug rendering of the physics scene. mod backend; +mod mpm; mod renderer; pub use backend::{DebugLine, DebugPoint, LineCollector, hsla_to_rgba}; +pub use mpm::MpmDebugRenderMode; pub use renderer::{DebugRenderSettings, DebugRenderer}; diff --git a/src_viewer/debug_render/mpm.rs b/src_viewer/debug_render/mpm.rs new file mode 100644 index 0000000..f116852 --- /dev/null +++ b/src_viewer/debug_render/mpm.rs @@ -0,0 +1,178 @@ +//! Debug rendering of the MPM state: the particles and the sparse grid. + +use super::backend::{DebugLine, DebugPoint}; +use bitflags::bitflags; +use nexus::mpm::debug::{DebugGrid, DebugParticle}; +use nexus::rbd::math::Vector; + +bitflags! { + /// Which parts of the MPM state to draw (like rapier's `DebugRenderMode`). + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] + pub struct MpmDebugRenderMode: u32 { + /// A point per particle, red if the particle is pinned. + const PARTICLES = 1 << 0; + /// A segment per particle along its velocity. + const PARTICLE_VELOCITIES = 1 << 1; + /// A segment per particle along its CPIC contact normal. + const PARTICLE_CDF = 1 << 2; + /// The box of each allocated grid block. + const GRID_BLOCKS = 1 << 3; + /// A point per grid node with mass. + const GRID_NODES = 1 << 4; + /// A segment per grid node with mass along its velocity. + const GRID_VELOCITIES = 1 << 5; + } +} + +impl MpmDebugRenderMode { + /// Whether the particles must be read back. + pub fn needs_particles(self) -> bool { + self.intersects(Self::PARTICLES | Self::PARTICLE_VELOCITIES | Self::PARTICLE_CDF) + } + + /// Whether the grid must be read back. + pub fn needs_grid(self) -> bool { + self.intersects(Self::GRID_BLOCKS | Self::GRID_NODES | Self::GRID_VELOCITIES) + } +} + +/// Fixed colors: rapier's `DebugRenderStyle` has no MPM entries. +const PARTICLE_COLOR: [f32; 4] = [0.35, 0.75, 1.0, 1.0]; +const PARTICLE_FIXED_COLOR: [f32; 4] = [1.0, 0.35, 0.35, 1.0]; +const PARTICLE_VELOCITY_COLOR: [f32; 4] = [0.2, 1.0, 0.6, 1.0]; +const PARTICLE_CDF_COLOR: [f32; 4] = [1.0, 0.85, 0.2, 1.0]; +/// Blocks with their own particles, and blocks that only exist because of +/// a neighbor's particles. +const BLOCK_COLOR: [f32; 4] = [0.45, 0.45, 0.55, 0.8]; +const BLOCK_EMPTY_COLOR: [f32; 4] = [0.3, 0.25, 0.4, 0.5]; +const NODE_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; +const NODE_VELOCITY_COLOR: [f32; 4] = [1.0, 0.6, 0.1, 1.0]; + +/// Draws the particles into `lines`/`points`. +/// `velocity_scale` is the time (in seconds) a velocity segment stands for. +pub fn render_particles( + particles: &[DebugParticle], + mode: MpmDebugRenderMode, + velocity_scale: f32, + lines: &mut Vec, + points: &mut Vec, +) { + for particle in particles { + if !particle.enabled { + continue; + } + + if mode.contains(MpmDebugRenderMode::PARTICLES) { + points.push(DebugPoint { + point: particle.position, + color: if particle.fixed { + PARTICLE_FIXED_COLOR + } else { + PARTICLE_COLOR + }, + }); + } + + if mode.contains(MpmDebugRenderMode::PARTICLE_VELOCITIES) { + lines.push(DebugLine { + a: particle.position, + b: particle.position + particle.velocity * velocity_scale, + color: PARTICLE_VELOCITY_COLOR, + }); + } + + // The normal is only set when a collider is close (it is zero otherwise). + if mode.contains(MpmDebugRenderMode::PARTICLE_CDF) && particle.cdf_affinity != 0 { + lines.push(DebugLine { + a: particle.position, + b: particle.position + particle.cdf_normal * particle.radius * 4.0, + color: PARTICLE_CDF_COLOR, + }); + } + } +} + +/// Draws the sparse grid into `lines`/`points`. +/// `velocity_scale` is the time (in seconds) a velocity segment stands for. +pub fn render_grid( + grid: &DebugGrid, + mode: MpmDebugRenderMode, + velocity_scale: f32, + lines: &mut Vec, + points: &mut Vec, +) { + if mode.contains(MpmDebugRenderMode::GRID_BLOCKS) { + for block in &grid.blocks { + let color = if block.num_particles > 0 { + BLOCK_COLOR + } else { + BLOCK_EMPTY_COLOR + }; + push_box(block.mins, block.maxs, color, lines); + } + } + + if !mode.intersects(MpmDebugRenderMode::GRID_NODES | MpmDebugRenderMode::GRID_VELOCITIES) { + return; + } + + for node in &grid.nodes { + if mode.contains(MpmDebugRenderMode::GRID_NODES) { + points.push(DebugPoint { + point: node.position, + color: NODE_COLOR, + }); + } + if mode.contains(MpmDebugRenderMode::GRID_VELOCITIES) { + lines.push(DebugLine { + a: node.position, + b: node.position + node.velocity * velocity_scale, + color: NODE_VELOCITY_COLOR, + }); + } + } +} + +/// Adds the edges of an axis-aligned box: 4 segments in 2D, 12 in 3D. +#[cfg(feature = "dim2")] +fn push_box(mins: Vector, maxs: Vector, color: [f32; 4], lines: &mut Vec) { + let corners = [ + mins, + Vector::new(maxs.x, mins.y), + maxs, + Vector::new(mins.x, maxs.y), + ]; + for i in 0..4 { + lines.push(DebugLine { + a: corners[i], + b: corners[(i + 1) % 4], + color, + }); + } +} + +/// Adds the edges of an axis-aligned box: 4 segments in 2D, 12 in 3D. +#[cfg(feature = "dim3")] +fn push_box(mins: Vector, maxs: Vector, color: [f32; 4], lines: &mut Vec) { + // Corner `i` takes x/y/z from `maxs` where bit 0/1/2 of `i` is set. + // Two corners share an edge if `i ^ j` has a single bit. + let corner = |i: usize| { + Vector::new( + if i & 1 != 0 { maxs.x } else { mins.x }, + if i & 2 != 0 { maxs.y } else { mins.y }, + if i & 4 != 0 { maxs.z } else { mins.z }, + ) + }; + for i in 0..8usize { + for bit in [1, 2, 4] { + let j = i ^ bit; + if j > i { + lines.push(DebugLine { + a: corner(i), + b: corner(j), + color, + }); + } + } + } +} diff --git a/src_viewer/debug_render/renderer.rs b/src_viewer/debug_render/renderer.rs index 37d38f1..6681f97 100644 --- a/src_viewer/debug_render/renderer.rs +++ b/src_viewer/debug_render/renderer.rs @@ -1,6 +1,7 @@ //! The debug renderer: turns the GPU state into debug segments. use super::backend::{DebugLine, DebugPoint, LineCollector, hsla_to_rgba}; +use super::mpm::{MpmDebugRenderMode, render_grid, render_particles}; use crate::rapier::pipeline::{DebugRenderMode, DebugRenderPipeline, DebugRenderStyle}; use crate::rapier::prelude::{ColliderSet, RigidBodyHandle, RigidBodySet}; use khal::backend::{Backend, GpuBackend}; @@ -12,8 +13,10 @@ use nexus::state::NexusState; pub struct DebugRenderSettings { /// Main switch. When off, nothing is drawn or read back. pub enabled: bool, - /// What to draw (see [`DebugRenderMode`]). + /// What to draw for the rigid-bodies (see [`DebugRenderMode`]). pub mode: DebugRenderMode, + /// What to draw for MPM (see [`MpmDebugRenderMode`]). + pub mpm_mode: MpmDebugRenderMode, /// Width of the debug segments, in pixels. pub line_width: f32, /// Size of the debug points, in pixels. @@ -22,6 +25,8 @@ pub struct DebugRenderSettings { pub contact_normal_length: f32, /// World-space length of the rigid-body axes. pub rigid_body_axes_length: f32, + /// The time (in seconds) an MPM velocity segment stands for. + pub mpm_velocity_scale: f32, } impl Default for DebugRenderSettings { @@ -34,10 +39,13 @@ impl Default for DebugRenderSettings { mode: DebugRenderMode::CONTACTS | DebugRenderMode::JOINTS | DebugRenderMode::RIGID_BODY_AXES, + // The viewer already draws the particles, so only the grid is on by default. + mpm_mode: MpmDebugRenderMode::GRID_BLOCKS, line_width: 2.0, point_size: 6.0, contact_normal_length: style.contact_normal_length, rigid_body_axes_length: style.rigid_body_axes_length, + mpm_velocity_scale: 0.1, } } } @@ -90,34 +98,71 @@ impl DebugRenderer { self.lines.clear(); self.points.clear(); - if !settings.enabled || settings.mode.is_empty() { + if !settings.enabled { return; } - let Some(rbd) = state.rbd.as_ref() else { - return; - }; - - self.pipeline.mode = settings.mode; - self.pipeline.style.contact_normal_length = settings.contact_normal_length; - self.pipeline.style.rigid_body_axes_length = settings.rigid_body_axes_length; - - // Only the wireframes need the poses, so skip the readback for contacts alone. - let wireframe = DebugRenderMode::COLLIDER_SHAPES - | DebugRenderMode::COLLIDER_AABBS - | DebugRenderMode::RIGID_BODY_AXES - | DebugRenderMode::JOINTS; - if settings.mode.intersects(wireframe) { - let poses = backend - .slow_read_vec::(rbd.body_poses().buffer()) - .await - .unwrap_or_default(); - self.sync_mirrors(state, &poses); - self.render_wireframes(state); + + if let Some(rbd) = state.rbd.as_ref() + && !settings.mode.is_empty() + { + self.pipeline.mode = settings.mode; + self.pipeline.style.contact_normal_length = settings.contact_normal_length; + self.pipeline.style.rigid_body_axes_length = settings.rigid_body_axes_length; + + // Only the wireframes need the poses, so skip the readback for contacts alone. + let wireframe = DebugRenderMode::COLLIDER_SHAPES + | DebugRenderMode::COLLIDER_AABBS + | DebugRenderMode::RIGID_BODY_AXES + | DebugRenderMode::JOINTS; + if settings.mode.intersects(wireframe) { + let poses = backend + .slow_read_vec::(rbd.body_poses().buffer()) + .await + .unwrap_or_default(); + self.sync_mirrors(state, &poses); + self.render_wireframes(state); + } + + let contact_modes = DebugRenderMode::CONTACTS | DebugRenderMode::SOLVER_CONTACTS; + if settings.mode.intersects(contact_modes) { + self.render_contacts(rbd, backend, settings).await; + } + } + + if let Some(mpm) = state.mpm.as_ref() + && !settings.mpm_mode.is_empty() + { + self.render_mpm(mpm, backend, settings).await; } + } - let contact_modes = DebugRenderMode::CONTACTS | DebugRenderMode::SOLVER_CONTACTS; - if settings.mode.intersects(contact_modes) { - self.render_contacts(rbd, backend, settings).await; + /// Draws the MPM particles and grid, read back from the GPU. + async fn render_mpm( + &mut self, + mpm: &nexus::mpm::pipeline::MpmState, + backend: &GpuBackend, + settings: &DebugRenderSettings, + ) { + if settings.mpm_mode.needs_particles() { + let particles = mpm.debug_particles(backend).await; + render_particles( + &particles, + settings.mpm_mode, + settings.mpm_velocity_scale, + &mut self.lines, + &mut self.points, + ); + } + + if settings.mpm_mode.needs_grid() { + let grid = mpm.debug_grid(backend).await; + render_grid( + &grid, + settings.mpm_mode, + settings.mpm_velocity_scale, + &mut self.lines, + &mut self.points, + ); } } diff --git a/src_viewer/lib.rs b/src_viewer/lib.rs index 98db40f..f3baed1 100644 --- a/src_viewer/lib.rs +++ b/src_viewer/lib.rs @@ -19,7 +19,7 @@ mod ui; pub mod viewer; pub use backend::BackendType; -pub use debug_render::{DebugRenderSettings, DebugRenderer}; +pub use debug_render::{DebugRenderSettings, DebugRenderer, MpmDebugRenderMode}; #[cfg(feature = "dim3")] pub use graphics::{RenderMaterial, VisualTexture}; #[cfg(feature = "dim3")] diff --git a/src_viewer/ui.rs b/src_viewer/ui.rs index cb8804b..fe09a9e 100644 --- a/src_viewer/ui.rs +++ b/src_viewer/ui.rs @@ -6,7 +6,7 @@ use nexus::state::NexusCounts; use std::time::Duration; use crate::backend::BackendType; -use crate::debug_render::DebugRenderSettings; +use crate::debug_render::{DebugRenderSettings, MpmDebugRenderMode}; use crate::rapier::pipeline::DebugRenderMode; use egui::{Button, CollapsingHeader, Color32, ComboBox, CornerRadius, RichText, Stroke}; @@ -226,14 +226,19 @@ fn simulation_settings(ui: &mut egui::Ui, state: &mut UiState) { } ui.label("Rigid bodies"); ui.add(egui::Slider::new(&mut s.rbd_steps_per_frame, 1..=20).text("steps / frame")); - - ui.add_space(4.0); - debug_render_settings(ui, &mut state.debug_render); } + + ui.add_space(4.0); + debug_render_settings(ui, &mut state.debug_render, has_mpm, has_rbd); } /// Debug-renderer controls: what to draw, and how. -fn debug_render_settings(ui: &mut egui::Ui, s: &mut DebugRenderSettings) { +fn debug_render_settings( + ui: &mut egui::Ui, + s: &mut DebugRenderSettings, + has_mpm: bool, + has_rbd: bool, +) { ui.checkbox(&mut s.enabled, "Debug render").on_hover_text( "Wireframe overlay of the physics state. Reads the scene back from the \ GPU every frame, so it slows the viewer down.", @@ -246,73 +251,142 @@ fn debug_render_settings(ui: &mut egui::Ui, s: &mut DebugRenderSettings) { .id_salt("debug_render_options") .default_open(true) .show(ui, |ui| { - let mut flag = |ui: &mut egui::Ui, mode: DebugRenderMode, label: &str, hover: &str| { - let mut on = s.mode.contains(mode); - if ui.checkbox(&mut on, label).on_hover_text(hover).changed() { - s.mode.set(mode, on); + if has_rbd { + if has_mpm { + ui.label("Rigid bodies"); } - }; + let mut flag = + |ui: &mut egui::Ui, mode: DebugRenderMode, label: &str, hover: &str| { + let mut on = s.mode.contains(mode); + if ui.checkbox(&mut on, label).on_hover_text(hover).changed() { + s.mode.set(mode, on); + } + }; - flag( - ui, - DebugRenderMode::COLLIDER_SHAPES, - "Collider shapes", - "Wireframe outline of every collider, colored by body type", - ); - flag( - ui, - DebugRenderMode::COLLIDER_AABBS, - "Collider AABBs", - "World-space bounding box of every collider", - ); - flag( - ui, - DebugRenderMode::RIGID_BODY_AXES, - "Rigid-body axes", - "Local coordinate axes at each body's center of mass", - ); - flag( - ui, - DebugRenderMode::IMPULSE_JOINTS, - "Impulse joints", - "Anchor and separation segments of each impulse joint", - ); - flag( - ui, - DebugRenderMode::MULTIBODY_JOINTS, - "Multibody joints", - "Anchor and separation segments of each multibody joint", - ); - flag( - ui, - DebugRenderMode::CONTACTS, - "Contacts", - "Geometric contacts: the segment joining the two contact points, \ - plus the contact normal", - ); - flag( - ui, - DebugRenderMode::SOLVER_CONTACTS, - "Solver contacts", - "The points the contact constraints actually act on (the midpoint \ - of each contact pair)", - ); + flag( + ui, + DebugRenderMode::COLLIDER_SHAPES, + "Collider shapes", + "Wireframe outline of every collider, colored by body type", + ); + flag( + ui, + DebugRenderMode::COLLIDER_AABBS, + "Collider AABBs", + "World-space bounding box of every collider", + ); + flag( + ui, + DebugRenderMode::RIGID_BODY_AXES, + "Rigid-body axes", + "Local coordinate axes at each body's center of mass", + ); + flag( + ui, + DebugRenderMode::IMPULSE_JOINTS, + "Impulse joints", + "Anchor and separation segments of each impulse joint", + ); + flag( + ui, + DebugRenderMode::MULTIBODY_JOINTS, + "Multibody joints", + "Anchor and separation segments of each multibody joint", + ); + flag( + ui, + DebugRenderMode::CONTACTS, + "Contacts", + "Geometric contacts: the segment joining the two contact points, \ + plus the contact normal", + ); + flag( + ui, + DebugRenderMode::SOLVER_CONTACTS, + "Solver contacts", + "The points the contact constraints actually act on (the midpoint \ + of each contact pair)", + ); + } + + if has_mpm { + if has_rbd { + ui.add_space(4.0); + ui.label("MPM"); + } + let mut flag = + |ui: &mut egui::Ui, mode: MpmDebugRenderMode, label: &str, hover: &str| { + let mut on = s.mpm_mode.contains(mode); + if ui.checkbox(&mut on, label).on_hover_text(hover).changed() { + s.mpm_mode.set(mode, on); + } + }; + + flag( + ui, + MpmDebugRenderMode::PARTICLES, + "Particles", + "A point at every particle position; pinned particles are red", + ); + flag( + ui, + MpmDebugRenderMode::PARTICLE_VELOCITIES, + "Particle velocities", + "A segment per particle along its velocity", + ); + flag( + ui, + MpmDebugRenderMode::PARTICLE_CDF, + "Particle contact normals", + "The CPIC contact normal of each particle close enough to a \ + collider to see one", + ); + flag( + ui, + MpmDebugRenderMode::GRID_BLOCKS, + "Grid blocks", + "Wireframe box of every block the sparse grid allocated; dimmer \ + when the block only holds particles spilling in from a neighbour", + ); + flag( + ui, + MpmDebugRenderMode::GRID_NODES, + "Grid nodes", + "A point at every grid node carrying mass", + ); + flag( + ui, + MpmDebugRenderMode::GRID_VELOCITIES, + "Grid velocities", + "A segment per mass-carrying node along its velocity", + ); + } ui.add_space(4.0); ui.add(egui::Slider::new(&mut s.line_width, 0.5..=10.0).text("line width")) .on_hover_text("Width of the debug segments, in pixels"); ui.add(egui::Slider::new(&mut s.point_size, 1.0..=20.0).text("point size")) - .on_hover_text("Size of the contact points, in pixels"); - ui.add( - egui::Slider::new(&mut s.contact_normal_length, 0.01..=10.0) - .logarithmic(true) - .text("normal length"), - ); - ui.add( - egui::Slider::new(&mut s.rigid_body_axes_length, 0.01..=10.0) - .logarithmic(true) - .text("axes length"), - ); + .on_hover_text("Size of the debug points, in pixels"); + if has_rbd { + ui.add( + egui::Slider::new(&mut s.contact_normal_length, 0.01..=10.0) + .logarithmic(true) + .text("normal length"), + ); + ui.add( + egui::Slider::new(&mut s.rigid_body_axes_length, 0.01..=10.0) + .logarithmic(true) + .text("axes length"), + ); + } + if has_mpm { + ui.add( + egui::Slider::new(&mut s.mpm_velocity_scale, 0.001..=1.0) + .logarithmic(true) + .text("mpm velocity scale"), + ) + .on_hover_text("Seconds of travel an MPM velocity segment stands for"); + } }); } From 0d5f7009d0ea8c09cf7572bd260030de191ddda0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Fri, 25 Sep 2026 16:18:06 +0200 Subject: [PATCH 3/7] feat(viewer): debug-render tab, light-background colors and option to hide the regular rendering --- src_viewer/debug_render/mpm.rs | 20 +- src_viewer/debug_render/renderer.rs | 38 +++- src_viewer/lib.rs | 1 + src_viewer/ui.rs | 286 ++++++++++++++-------------- src_viewer/viewer.rs | 33 +++- 5 files changed, 223 insertions(+), 155 deletions(-) diff --git a/src_viewer/debug_render/mpm.rs b/src_viewer/debug_render/mpm.rs index f116852..8fa047a 100644 --- a/src_viewer/debug_render/mpm.rs +++ b/src_viewer/debug_render/mpm.rs @@ -36,17 +36,17 @@ impl MpmDebugRenderMode { } } -/// Fixed colors: rapier's `DebugRenderStyle` has no MPM entries. -const PARTICLE_COLOR: [f32; 4] = [0.35, 0.75, 1.0, 1.0]; -const PARTICLE_FIXED_COLOR: [f32; 4] = [1.0, 0.35, 0.35, 1.0]; -const PARTICLE_VELOCITY_COLOR: [f32; 4] = [0.2, 1.0, 0.6, 1.0]; -const PARTICLE_CDF_COLOR: [f32; 4] = [1.0, 0.85, 0.2, 1.0]; +/// Dark colors that are easy to see on the bright background. +const PARTICLE_COLOR: [f32; 4] = [0.0, 0.35, 0.8, 1.0]; +const PARTICLE_FIXED_COLOR: [f32; 4] = [0.8, 0.0, 0.0, 1.0]; +const PARTICLE_VELOCITY_COLOR: [f32; 4] = [0.0, 0.55, 0.25, 1.0]; +const PARTICLE_CDF_COLOR: [f32; 4] = [0.7, 0.55, 0.0, 1.0]; /// Blocks with their own particles, and blocks that only exist because of -/// a neighbor's particles. -const BLOCK_COLOR: [f32; 4] = [0.45, 0.45, 0.55, 0.8]; -const BLOCK_EMPTY_COLOR: [f32; 4] = [0.3, 0.25, 0.4, 0.5]; -const NODE_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; -const NODE_VELOCITY_COLOR: [f32; 4] = [1.0, 0.6, 0.1, 1.0]; +/// a neighbor's particles (lighter). +const BLOCK_COLOR: [f32; 4] = [0.25, 0.25, 0.45, 0.9]; +const BLOCK_EMPTY_COLOR: [f32; 4] = [0.6, 0.5, 0.8, 0.7]; +const NODE_COLOR: [f32; 4] = [0.1, 0.1, 0.1, 1.0]; +const NODE_VELOCITY_COLOR: [f32; 4] = [0.85, 0.25, 0.0, 1.0]; /// Draws the particles into `lines`/`points`. /// `velocity_scale` is the time (in seconds) a velocity segment stands for. diff --git a/src_viewer/debug_render/renderer.rs b/src_viewer/debug_render/renderer.rs index 6681f97..fed4ed1 100644 --- a/src_viewer/debug_render/renderer.rs +++ b/src_viewer/debug_render/renderer.rs @@ -27,6 +27,9 @@ pub struct DebugRenderSettings { pub rigid_body_axes_length: f32, /// The time (in seconds) an MPM velocity segment stands for. pub mpm_velocity_scale: f32, + /// Hide the regular rendering, to only show the debug lines and points. + /// Only used if [`Self::enabled`] is set. + pub hide_regular_rendering: bool, } impl Default for DebugRenderSettings { @@ -46,10 +49,29 @@ impl Default for DebugRenderSettings { contact_normal_length: style.contact_normal_length, rigid_body_axes_length: style.rigid_body_axes_length, mpm_velocity_scale: 0.1, + hide_regular_rendering: false, } } } +/// rapier's debug colors, made darker where needed to be visible on the bright background. +fn light_background_style() -> DebugRenderStyle { + DebugRenderStyle { + collider_dynamic_color: [340.0, 1.0, 0.35, 1.0], + collider_kinematic_color: [20.0, 1.0, 0.35, 1.0], + collider_fixed_color: [30.0, 0.9, 0.3, 1.0], + collider_parentless_color: [30.0, 0.9, 0.3, 1.0], + impulse_joint_anchor_color: [240.0, 0.9, 0.4, 1.0], + impulse_joint_separation_color: [0.0, 0.9, 0.4, 1.0], + multibody_joint_anchor_color: [290.0, 0.9, 0.4, 1.0], + multibody_joint_separation_color: [0.0, 0.9, 0.4, 1.0], + contact_depth_color: [140.0, 1.0, 0.3, 1.0], + contact_normal_color: [210.0, 1.0, 0.4, 1.0], + collider_aabb_color: [270.0, 0.8, 0.45, 1.0], + ..DebugRenderStyle::default() + } +} + /// A copy of the rapier sets of one environment, with poses updated from the GPU. struct WorldMirror { bodies: RigidBodySet, @@ -57,7 +79,6 @@ struct WorldMirror { } /// Draws the scene as wireframes and contact markers. -#[derive(Default)] pub struct DebugRenderer { pipeline: DebugRenderPipeline, mirrors: Vec, @@ -68,6 +89,21 @@ pub struct DebugRenderer { points: Vec, } +impl Default for DebugRenderer { + fn default() -> Self { + Self { + pipeline: DebugRenderPipeline::new( + light_background_style(), + DebugRenderMode::default(), + ), + mirrors: Vec::new(), + mirrored_counts: Vec::new(), + lines: Vec::new(), + points: Vec::new(), + } + } +} + impl DebugRenderer { /// The segments to draw this frame. pub fn lines(&self) -> &[DebugLine] { diff --git a/src_viewer/lib.rs b/src_viewer/lib.rs index f3baed1..9d7fb8a 100644 --- a/src_viewer/lib.rs +++ b/src_viewer/lib.rs @@ -38,6 +38,7 @@ pub struct UiSections { pub show_examples: bool, pub show_settings: bool, pub show_performance: bool, + pub show_debug_render: bool, } /// The kind of solver a registered demo uses. Used only to group demos in the diff --git a/src_viewer/ui.rs b/src_viewer/ui.rs index fe09a9e..d588974 100644 --- a/src_viewer/ui.rs +++ b/src_viewer/ui.rs @@ -134,6 +134,7 @@ pub fn main_panel(ctx: &egui::Context, state: &mut UiState, gpu_available: bool) ui.toggle_value(&mut state.ui_sections.show_performance, "Performance"); ui.toggle_value(&mut state.ui_sections.show_settings, "Settings"); ui.toggle_value(&mut state.ui_sections.show_examples, "Examples"); + ui.toggle_value(&mut state.ui_sections.show_debug_render, "Debug render"); }); egui::ScrollArea::vertical() @@ -155,6 +156,16 @@ pub fn main_panel(ctx: &egui::Context, state: &mut UiState, gpu_available: bool) ui.separator(); examples_section(ui, state); } + + if state.ui_sections.show_debug_render { + ui.separator(); + debug_render_settings( + ui, + &mut state.debug_render, + state.has_mpm, + state.has_rbd, + ); + } }); ui.separator(); @@ -227,9 +238,6 @@ fn simulation_settings(ui: &mut egui::Ui, state: &mut UiState) { ui.label("Rigid bodies"); ui.add(egui::Slider::new(&mut s.rbd_steps_per_frame, 1..=20).text("steps / frame")); } - - ui.add_space(4.0); - debug_render_settings(ui, &mut state.debug_render, has_mpm, has_rbd); } /// Debug-renderer controls: what to draw, and how. @@ -239,155 +247,153 @@ fn debug_render_settings( has_mpm: bool, has_rbd: bool, ) { - ui.checkbox(&mut s.enabled, "Debug render").on_hover_text( + ui.label(RichText::new("Debug render").strong()); + ui.add_space(2.0); + ui.checkbox(&mut s.enabled, "Enabled").on_hover_text( "Wireframe overlay of the physics state. Reads the scene back from the \ GPU every frame, so it slows the viewer down.", ); if !s.enabled { return; } + ui.checkbox(&mut s.hide_regular_rendering, "Hide regular rendering") + .on_hover_text("Draw only the debug lines and points, without the shaded scene"); + ui.add_space(4.0); - CollapsingHeader::new("Debug render options") - .id_salt("debug_render_options") - .default_open(true) - .show(ui, |ui| { - if has_rbd { - if has_mpm { - ui.label("Rigid bodies"); - } - let mut flag = - |ui: &mut egui::Ui, mode: DebugRenderMode, label: &str, hover: &str| { - let mut on = s.mode.contains(mode); - if ui.checkbox(&mut on, label).on_hover_text(hover).changed() { - s.mode.set(mode, on); - } - }; - - flag( - ui, - DebugRenderMode::COLLIDER_SHAPES, - "Collider shapes", - "Wireframe outline of every collider, colored by body type", - ); - flag( - ui, - DebugRenderMode::COLLIDER_AABBS, - "Collider AABBs", - "World-space bounding box of every collider", - ); - flag( - ui, - DebugRenderMode::RIGID_BODY_AXES, - "Rigid-body axes", - "Local coordinate axes at each body's center of mass", - ); - flag( - ui, - DebugRenderMode::IMPULSE_JOINTS, - "Impulse joints", - "Anchor and separation segments of each impulse joint", - ); - flag( - ui, - DebugRenderMode::MULTIBODY_JOINTS, - "Multibody joints", - "Anchor and separation segments of each multibody joint", - ); - flag( - ui, - DebugRenderMode::CONTACTS, - "Contacts", - "Geometric contacts: the segment joining the two contact points, \ - plus the contact normal", - ); - flag( - ui, - DebugRenderMode::SOLVER_CONTACTS, - "Solver contacts", - "The points the contact constraints actually act on (the midpoint \ - of each contact pair)", - ); + if has_rbd { + if has_mpm { + ui.label("Rigid bodies"); + } + let mut flag = |ui: &mut egui::Ui, mode: DebugRenderMode, label: &str, hover: &str| { + let mut on = s.mode.contains(mode); + if ui.checkbox(&mut on, label).on_hover_text(hover).changed() { + s.mode.set(mode, on); } + }; - if has_mpm { - if has_rbd { - ui.add_space(4.0); - ui.label("MPM"); - } - let mut flag = - |ui: &mut egui::Ui, mode: MpmDebugRenderMode, label: &str, hover: &str| { - let mut on = s.mpm_mode.contains(mode); - if ui.checkbox(&mut on, label).on_hover_text(hover).changed() { - s.mpm_mode.set(mode, on); - } - }; - - flag( - ui, - MpmDebugRenderMode::PARTICLES, - "Particles", - "A point at every particle position; pinned particles are red", - ); - flag( - ui, - MpmDebugRenderMode::PARTICLE_VELOCITIES, - "Particle velocities", - "A segment per particle along its velocity", - ); - flag( - ui, - MpmDebugRenderMode::PARTICLE_CDF, - "Particle contact normals", - "The CPIC contact normal of each particle close enough to a \ - collider to see one", - ); - flag( - ui, - MpmDebugRenderMode::GRID_BLOCKS, - "Grid blocks", - "Wireframe box of every block the sparse grid allocated; dimmer \ - when the block only holds particles spilling in from a neighbour", - ); - flag( - ui, - MpmDebugRenderMode::GRID_NODES, - "Grid nodes", - "A point at every grid node carrying mass", - ); - flag( - ui, - MpmDebugRenderMode::GRID_VELOCITIES, - "Grid velocities", - "A segment per mass-carrying node along its velocity", - ); - } + flag( + ui, + DebugRenderMode::COLLIDER_SHAPES, + "Collider shapes", + "Wireframe outline of every collider, colored by body type", + ); + flag( + ui, + DebugRenderMode::COLLIDER_AABBS, + "Collider AABBs", + "World-space bounding box of every collider", + ); + flag( + ui, + DebugRenderMode::RIGID_BODY_AXES, + "Rigid-body axes", + "Local coordinate axes at each body's center of mass", + ); + flag( + ui, + DebugRenderMode::IMPULSE_JOINTS, + "Impulse joints", + "Anchor and separation segments of each impulse joint", + ); + flag( + ui, + DebugRenderMode::MULTIBODY_JOINTS, + "Multibody joints", + "Anchor and separation segments of each multibody joint", + ); + flag( + ui, + DebugRenderMode::CONTACTS, + "Contacts", + "Geometric contacts: the segment joining the two contact points, \ + plus the contact normal", + ); + flag( + ui, + DebugRenderMode::SOLVER_CONTACTS, + "Solver contacts", + "The points the contact constraints actually act on (the midpoint \ + of each contact pair)", + ); + } + if has_mpm { + if has_rbd { ui.add_space(4.0); - ui.add(egui::Slider::new(&mut s.line_width, 0.5..=10.0).text("line width")) - .on_hover_text("Width of the debug segments, in pixels"); - ui.add(egui::Slider::new(&mut s.point_size, 1.0..=20.0).text("point size")) - .on_hover_text("Size of the debug points, in pixels"); - if has_rbd { - ui.add( - egui::Slider::new(&mut s.contact_normal_length, 0.01..=10.0) - .logarithmic(true) - .text("normal length"), - ); - ui.add( - egui::Slider::new(&mut s.rigid_body_axes_length, 0.01..=10.0) - .logarithmic(true) - .text("axes length"), - ); - } - if has_mpm { - ui.add( - egui::Slider::new(&mut s.mpm_velocity_scale, 0.001..=1.0) - .logarithmic(true) - .text("mpm velocity scale"), - ) - .on_hover_text("Seconds of travel an MPM velocity segment stands for"); + ui.label("MPM"); + } + let mut flag = |ui: &mut egui::Ui, mode: MpmDebugRenderMode, label: &str, hover: &str| { + let mut on = s.mpm_mode.contains(mode); + if ui.checkbox(&mut on, label).on_hover_text(hover).changed() { + s.mpm_mode.set(mode, on); } - }); + }; + + flag( + ui, + MpmDebugRenderMode::PARTICLES, + "Particles", + "A point at every particle position; pinned particles are red", + ); + flag( + ui, + MpmDebugRenderMode::PARTICLE_VELOCITIES, + "Particle velocities", + "A segment per particle along its velocity", + ); + flag( + ui, + MpmDebugRenderMode::PARTICLE_CDF, + "Particle contact normals", + "The CPIC contact normal of each particle close enough to a \ + collider to see one", + ); + flag( + ui, + MpmDebugRenderMode::GRID_BLOCKS, + "Grid blocks", + "Wireframe box of every block the sparse grid allocated; dimmer \ + when the block only holds particles spilling in from a neighbour", + ); + flag( + ui, + MpmDebugRenderMode::GRID_NODES, + "Grid nodes", + "A point at every grid node carrying mass", + ); + flag( + ui, + MpmDebugRenderMode::GRID_VELOCITIES, + "Grid velocities", + "A segment per mass-carrying node along its velocity", + ); + } + + ui.add_space(4.0); + ui.add(egui::Slider::new(&mut s.line_width, 0.5..=10.0).text("line width")) + .on_hover_text("Width of the debug segments, in pixels"); + ui.add(egui::Slider::new(&mut s.point_size, 1.0..=20.0).text("point size")) + .on_hover_text("Size of the debug points, in pixels"); + if has_rbd { + ui.add( + egui::Slider::new(&mut s.contact_normal_length, 0.01..=10.0) + .logarithmic(true) + .text("normal length"), + ); + ui.add( + egui::Slider::new(&mut s.rigid_body_axes_length, 0.01..=10.0) + .logarithmic(true) + .text("axes length"), + ); + } + if has_mpm { + ui.add( + egui::Slider::new(&mut s.mpm_velocity_scale, 0.001..=1.0) + .logarithmic(true) + .text("mpm velocity scale"), + ) + .on_hover_text("Seconds of travel an MPM velocity segment stands for"); + } } /// A labelled per-component drag editor for a gravity vector (2D or 3D). diff --git a/src_viewer/viewer.rs b/src_viewer/viewer.rs index ae7242e..b094923 100644 --- a/src_viewer/viewer.rs +++ b/src_viewer/viewer.rs @@ -419,6 +419,7 @@ impl NexusViewer { show_examples: true, show_settings: false, show_performance: true, + show_debug_render: false, }, backend_type: BackendType::Gpu, compute_graphs: false, @@ -1414,7 +1415,7 @@ impl NexusViewer { let point_size = self.ui.debug_render.point_size; for line in self.debug_renderer.lines() { - let color = Color::new(line.color[0], line.color[1], line.color[2], line.color[3]); + let color = debug_color(line.color); #[cfg(feature = "dim3")] self.window .draw_line(line.a, line.b, color, line_width, false); @@ -1423,7 +1424,7 @@ impl NexusViewer { } for pt in self.debug_renderer.points() { - let color = Color::new(pt.color[0], pt.color[1], pt.color[2], pt.color[3]); + let color = debug_color(pt.color); #[cfg(feature = "dim3")] self.window.draw_point(pt.point, color, point_size); #[cfg(feature = "dim2")] @@ -1532,11 +1533,22 @@ impl NexusViewer { // Added before `render`, which draws then clears the point/line renderers. self.draw_debug_render(); + // To hide the regular rendering, render empty scenes instead of the real ones. + let hide_scene = + self.ui.debug_render.enabled && self.ui.debug_render.hide_regular_rendering; + let mut empty_scene3d = SceneNode3d::empty(); + let mut empty_scene2d = SceneNode2d::empty(); + let (scene3d, scene2d) = if hide_scene { + (&mut empty_scene3d, &mut empty_scene2d) + } else { + (&mut self.scene3d, &mut self.scene2d) + }; + let cont = self .window .render( - Some(&mut self.scene3d), - Some(&mut self.scene2d), + Some(scene3d), + Some(scene2d), Some(&mut self.camera3d), Some(&mut self.camera2d), None, @@ -1719,3 +1731,16 @@ fn build_mpm_instances(instances: &[ReadbackData]) -> Vec { }) .collect() } + +/// Converts a debug color from sRGB to the linear color kiss3d expects. +/// Without it, all the debug colors look too bright. +fn debug_color([r, g, b, a]: [f32; 4]) -> Color { + fn to_linear(c: f32) -> f32 { + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + } + Color::new(to_linear(r), to_linear(g), to_linear(b), a) +} From 3ed1f9b14222f5b92c081d9b5a5fb0a992780e2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Fri, 25 Sep 2026 18:11:46 +0200 Subject: [PATCH 4/7] feat(viewer): make the panel tabs exclusive --- src_viewer/lib.rs | 13 +++++++------ src_viewer/ui.rs | 33 +++++++++++++++++++-------------- src_viewer/viewer.rs | 12 ++++-------- 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src_viewer/lib.rs b/src_viewer/lib.rs index 9d7fb8a..85aab2a 100644 --- a/src_viewer/lib.rs +++ b/src_viewer/lib.rs @@ -33,12 +33,13 @@ pub enum RunState { Step, } -#[derive(Copy, Clone)] -pub struct UiSections { - pub show_examples: bool, - pub show_settings: bool, - pub show_performance: bool, - pub show_debug_render: bool, +/// The tabs of the viewer panel. Only one is shown at a time. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum UiSection { + Performance, + Settings, + Examples, + DebugRender, } /// The kind of solver a registered demo uses. Used only to group demos in the diff --git a/src_viewer/ui.rs b/src_viewer/ui.rs index d588974..5778fc1 100644 --- a/src_viewer/ui.rs +++ b/src_viewer/ui.rs @@ -1,5 +1,5 @@ use crate::viewer::{MpmRenderMode, UiState}; -use crate::{DemoKind, RunState, Transition}; +use crate::{DemoKind, RunState, Transition, UiSection}; use kiss3d::egui; use nexus::rbd::pipeline::RunStats; use nexus::state::NexusCounts; @@ -129,35 +129,39 @@ pub fn main_panel(ctx: &egui::Context, state: &mut UiState, gpu_available: bool) ui.separator(); } - // Section toggles. + // Tabs: one section at a time. Clicking the open tab again closes it. ui.horizontal(|ui| { - ui.toggle_value(&mut state.ui_sections.show_performance, "Performance"); - ui.toggle_value(&mut state.ui_sections.show_settings, "Settings"); - ui.toggle_value(&mut state.ui_sections.show_examples, "Examples"); - ui.toggle_value(&mut state.ui_sections.show_debug_render, "Debug render"); + for (section, label) in [ + (UiSection::Performance, "Performance"), + (UiSection::Settings, "Settings"), + (UiSection::Examples, "Examples"), + (UiSection::DebugRender, "Debug render"), + ] { + let selected = state.ui_section == Some(section); + if ui.selectable_label(selected, label).clicked() { + state.ui_section = if selected { None } else { Some(section) }; + } + } }); egui::ScrollArea::vertical() .max_height(500.0) - .show(ui, |ui| { - if state.ui_sections.show_settings { + .show(ui, |ui| match state.ui_section { + Some(UiSection::Settings) => { ui.separator(); backend_selector(ui, state, gpu_available); ui.add_space(4.0); simulation_settings(ui, state); } - - if state.ui_sections.show_performance { + Some(UiSection::Performance) => { ui.separator(); performance_ui(ui, &state.counts, &state.run_stats, state.sync_time); } - - if state.ui_sections.show_examples && !state.demos.is_empty() { + Some(UiSection::Examples) if !state.demos.is_empty() => { ui.separator(); examples_section(ui, state); } - - if state.ui_sections.show_debug_render { + Some(UiSection::DebugRender) => { ui.separator(); debug_render_settings( ui, @@ -166,6 +170,7 @@ pub fn main_panel(ctx: &egui::Context, state: &mut UiState, gpu_available: bool) state.has_rbd, ); } + _ => {} }); ui.separator(); diff --git a/src_viewer/viewer.rs b/src_viewer/viewer.rs index b094923..ee9bcb2 100644 --- a/src_viewer/viewer.rs +++ b/src_viewer/viewer.rs @@ -59,7 +59,7 @@ use crate::graphics::RenderContext; use crate::graphics::VisualTexture; #[cfg(feature = "dim3")] use crate::sensors::SensorCamera; -use crate::{DemoKind, RunState, Transition, UiSections}; +use crate::{DemoKind, RunState, Transition, UiSection}; /// Per-particle coloring mode for MPM rendering, written into the /// `WgPrepReadback` render config. Mirrors the `mode` values understood by the @@ -106,7 +106,8 @@ pub struct UiState { pub run_state: RunState, pub run_stats: RunStats, pub sync_time: Duration, - pub ui_sections: UiSections, + /// The tab currently shown, or `None` if the panel is closed. + pub ui_section: Option, pub backend_type: BackendType, /// Replay each frame's GPU work through a compute graph (backends with graph support only). pub compute_graphs: bool, @@ -415,12 +416,7 @@ impl NexusViewer { run_state: RunState::Paused, run_stats: RunStats::default(), sync_time: Duration::default(), - ui_sections: UiSections { - show_examples: true, - show_settings: false, - show_performance: true, - show_debug_render: false, - }, + ui_section: Some(UiSection::Examples), backend_type: BackendType::Gpu, compute_graphs: false, gpu_init_error: None, From 8e10aaa620f53d90a7773272449e1e87ad6ff466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Sat, 26 Sep 2026 17:15:56 +0200 Subject: [PATCH 5/7] feat(viewer): show every debug-render option whatever the scene --- src_viewer/ui.rs | 248 +++++++++++++++++++++-------------------------- 1 file changed, 113 insertions(+), 135 deletions(-) diff --git a/src_viewer/ui.rs b/src_viewer/ui.rs index 5778fc1..2429ce5 100644 --- a/src_viewer/ui.rs +++ b/src_viewer/ui.rs @@ -163,12 +163,7 @@ pub fn main_panel(ctx: &egui::Context, state: &mut UiState, gpu_available: bool) } Some(UiSection::DebugRender) => { ui.separator(); - debug_render_settings( - ui, - &mut state.debug_render, - state.has_mpm, - state.has_rbd, - ); + debug_render_settings(ui, &mut state.debug_render); } _ => {} }); @@ -246,12 +241,7 @@ fn simulation_settings(ui: &mut egui::Ui, state: &mut UiState) { } /// Debug-renderer controls: what to draw, and how. -fn debug_render_settings( - ui: &mut egui::Ui, - s: &mut DebugRenderSettings, - has_mpm: bool, - has_rbd: bool, -) { +fn debug_render_settings(ui: &mut egui::Ui, s: &mut DebugRenderSettings) { ui.label(RichText::new("Debug render").strong()); ui.add_space(2.0); ui.checkbox(&mut s.enabled, "Enabled").on_hover_text( @@ -265,140 +255,128 @@ fn debug_render_settings( .on_hover_text("Draw only the debug lines and points, without the shaded scene"); ui.add_space(4.0); - if has_rbd { - if has_mpm { - ui.label("Rigid bodies"); + ui.label("Rigid bodies"); + let mut flag = |ui: &mut egui::Ui, mode: DebugRenderMode, label: &str, hover: &str| { + let mut on = s.mode.contains(mode); + if ui.checkbox(&mut on, label).on_hover_text(hover).changed() { + s.mode.set(mode, on); } - let mut flag = |ui: &mut egui::Ui, mode: DebugRenderMode, label: &str, hover: &str| { - let mut on = s.mode.contains(mode); - if ui.checkbox(&mut on, label).on_hover_text(hover).changed() { - s.mode.set(mode, on); - } - }; + }; - flag( - ui, - DebugRenderMode::COLLIDER_SHAPES, - "Collider shapes", - "Wireframe outline of every collider, colored by body type", - ); - flag( - ui, - DebugRenderMode::COLLIDER_AABBS, - "Collider AABBs", - "World-space bounding box of every collider", - ); - flag( - ui, - DebugRenderMode::RIGID_BODY_AXES, - "Rigid-body axes", - "Local coordinate axes at each body's center of mass", - ); - flag( - ui, - DebugRenderMode::IMPULSE_JOINTS, - "Impulse joints", - "Anchor and separation segments of each impulse joint", - ); - flag( - ui, - DebugRenderMode::MULTIBODY_JOINTS, - "Multibody joints", - "Anchor and separation segments of each multibody joint", - ); - flag( - ui, - DebugRenderMode::CONTACTS, - "Contacts", - "Geometric contacts: the segment joining the two contact points, \ - plus the contact normal", - ); - flag( - ui, - DebugRenderMode::SOLVER_CONTACTS, - "Solver contacts", - "The points the contact constraints actually act on (the midpoint \ - of each contact pair)", - ); - } + flag( + ui, + DebugRenderMode::COLLIDER_SHAPES, + "Collider shapes", + "Wireframe outline of every collider, colored by body type", + ); + flag( + ui, + DebugRenderMode::COLLIDER_AABBS, + "Collider AABBs", + "World-space bounding box of every collider", + ); + flag( + ui, + DebugRenderMode::RIGID_BODY_AXES, + "Rigid-body axes", + "Local coordinate axes at each body's center of mass", + ); + flag( + ui, + DebugRenderMode::IMPULSE_JOINTS, + "Impulse joints", + "Anchor and separation segments of each impulse joint", + ); + flag( + ui, + DebugRenderMode::MULTIBODY_JOINTS, + "Multibody joints", + "Anchor and separation segments of each multibody joint", + ); + flag( + ui, + DebugRenderMode::CONTACTS, + "Contacts", + "Geometric contacts: the segment joining the two contact points, \ + plus the contact normal", + ); + flag( + ui, + DebugRenderMode::SOLVER_CONTACTS, + "Solver contacts", + "The points the contact constraints actually act on (the midpoint \ + of each contact pair)", + ); - if has_mpm { - if has_rbd { - ui.add_space(4.0); - ui.label("MPM"); + ui.add_space(4.0); + ui.label("MPM"); + let mut flag = |ui: &mut egui::Ui, mode: MpmDebugRenderMode, label: &str, hover: &str| { + let mut on = s.mpm_mode.contains(mode); + if ui.checkbox(&mut on, label).on_hover_text(hover).changed() { + s.mpm_mode.set(mode, on); } - let mut flag = |ui: &mut egui::Ui, mode: MpmDebugRenderMode, label: &str, hover: &str| { - let mut on = s.mpm_mode.contains(mode); - if ui.checkbox(&mut on, label).on_hover_text(hover).changed() { - s.mpm_mode.set(mode, on); - } - }; + }; - flag( - ui, - MpmDebugRenderMode::PARTICLES, - "Particles", - "A point at every particle position; pinned particles are red", - ); - flag( - ui, - MpmDebugRenderMode::PARTICLE_VELOCITIES, - "Particle velocities", - "A segment per particle along its velocity", - ); - flag( - ui, - MpmDebugRenderMode::PARTICLE_CDF, - "Particle contact normals", - "The CPIC contact normal of each particle close enough to a \ - collider to see one", - ); - flag( - ui, - MpmDebugRenderMode::GRID_BLOCKS, - "Grid blocks", - "Wireframe box of every block the sparse grid allocated; dimmer \ - when the block only holds particles spilling in from a neighbour", - ); - flag( - ui, - MpmDebugRenderMode::GRID_NODES, - "Grid nodes", - "A point at every grid node carrying mass", - ); - flag( - ui, - MpmDebugRenderMode::GRID_VELOCITIES, - "Grid velocities", - "A segment per mass-carrying node along its velocity", - ); - } + flag( + ui, + MpmDebugRenderMode::PARTICLES, + "Particles", + "A point at every particle position; pinned particles are red", + ); + flag( + ui, + MpmDebugRenderMode::PARTICLE_VELOCITIES, + "Particle velocities", + "A segment per particle along its velocity", + ); + flag( + ui, + MpmDebugRenderMode::PARTICLE_CDF, + "Particle contact normals", + "The CPIC contact normal of each particle close enough to a \ + collider to see one", + ); + flag( + ui, + MpmDebugRenderMode::GRID_BLOCKS, + "Grid blocks", + "Wireframe box of every block the sparse grid allocated; dimmer \ + when the block only holds particles spilling in from a neighbour", + ); + flag( + ui, + MpmDebugRenderMode::GRID_NODES, + "Grid nodes", + "A point at every grid node carrying mass", + ); + flag( + ui, + MpmDebugRenderMode::GRID_VELOCITIES, + "Grid velocities", + "A segment per mass-carrying node along its velocity", + ); ui.add_space(4.0); ui.add(egui::Slider::new(&mut s.line_width, 0.5..=10.0).text("line width")) .on_hover_text("Width of the debug segments, in pixels"); ui.add(egui::Slider::new(&mut s.point_size, 1.0..=20.0).text("point size")) .on_hover_text("Size of the debug points, in pixels"); - if has_rbd { - ui.add( - egui::Slider::new(&mut s.contact_normal_length, 0.01..=10.0) - .logarithmic(true) - .text("normal length"), - ); - ui.add( - egui::Slider::new(&mut s.rigid_body_axes_length, 0.01..=10.0) - .logarithmic(true) - .text("axes length"), - ); - } - if has_mpm { - ui.add( - egui::Slider::new(&mut s.mpm_velocity_scale, 0.001..=1.0) - .logarithmic(true) - .text("mpm velocity scale"), - ) - .on_hover_text("Seconds of travel an MPM velocity segment stands for"); - } + ui.add( + egui::Slider::new(&mut s.contact_normal_length, 0.01..=10.0) + .logarithmic(true) + .text("normal length"), + ); + ui.add( + egui::Slider::new(&mut s.rigid_body_axes_length, 0.01..=10.0) + .logarithmic(true) + .text("axes length"), + ); + ui.add( + egui::Slider::new(&mut s.mpm_velocity_scale, 0.001..=1.0) + .logarithmic(true) + .text("mpm velocity scale"), + ) + .on_hover_text("Seconds of travel an MPM velocity segment stands for"); } /// A labelled per-component drag editor for a gravity vector (2D or 3D). From 5f4503ea6896ef528544f93eb217c46ef15af273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Sun, 27 Sep 2026 18:26:01 +0200 Subject: [PATCH 6/7] feat(viewer): debug-render the broad-phase LBVH at a selectable depth --- src_rbd/pipeline/insertion_removal.rs | 7 +-- src_rbd/pipeline/mod.rs | 2 +- src_rbd/pipeline/rbd_state.rs | 75 ++++++++++++++++++++++- src_rbd/pipeline/rbd_state_from_rapier.rs | 7 +-- src_rbd/pipeline/rbd_step.rs | 5 +- src_viewer/debug_render/backend.rs | 44 +++++++++++++ src_viewer/debug_render/mod.rs | 2 +- src_viewer/debug_render/mpm.rs | 47 +------------- src_viewer/debug_render/renderer.rs | 69 ++++++++++++++++++++- src_viewer/lib.rs | 2 +- src_viewer/ui.rs | 47 +++++++++++++- src_viewer/viewer.rs | 6 +- 12 files changed, 243 insertions(+), 70 deletions(-) diff --git a/src_rbd/pipeline/insertion_removal.rs b/src_rbd/pipeline/insertion_removal.rs index 455ffad..420aafc 100644 --- a/src_rbd/pipeline/insertion_removal.rs +++ b/src_rbd/pipeline/insertion_removal.rs @@ -207,11 +207,8 @@ impl RbdState { let new_body_constraint_ids = Tensor::vector_uninit(backend, contacts_capacity * 2, storage).unwrap(); - let lbvh_usages = if crate::VALIDATE_LBVH_TOPOLOGY { - BufferUsages::STORAGE | BufferUsages::COPY_SRC - } else { - BufferUsages::STORAGE - }; + // `COPY_SRC` so the LBVH validation and the debug-renderer can read the tree. + let lbvh_usages = BufferUsages::STORAGE | BufferUsages::COPY_SRC; let contacts_capacity_cpu = contacts_capacity; let collision_pairs_capacity_cpu = pairs_capacity; diff --git a/src_rbd/pipeline/mod.rs b/src_rbd/pipeline/mod.rs index 7195a78..dc0aeb9 100644 --- a/src_rbd/pipeline/mod.rs +++ b/src_rbd/pipeline/mod.rs @@ -19,6 +19,6 @@ mod test_polyline_warmstart; #[cfg(feature = "dim3")] pub use rbd_state::RbdSnapshot; pub use rbd_state::{ - DebugContact, RbdCapacities, RbdGraphKey, RbdResizePolicy, RbdState, RunStats, + DebugContact, DebugLbvhNode, RbdCapacities, RbdGraphKey, RbdResizePolicy, RbdState, RunStats, }; pub use rbd_step::RbdPipeline; diff --git a/src_rbd/pipeline/rbd_state.rs b/src_rbd/pipeline/rbd_state.rs index c77622a..2d15893 100644 --- a/src_rbd/pipeline/rbd_state.rs +++ b/src_rbd/pipeline/rbd_state.rs @@ -1,14 +1,13 @@ //! GPU-resident rigid-body state ([`RbdState`]): buffer definitions, accessors, //! run statistics and capacity/resize policies. -use crate::broad_phase::{LbvhState, PfmSortState}; +use crate::broad_phase::{BRUTE_FORCE_MAX_COLLIDERS, LbvhState, PfmSortState}; use crate::dynamics::GpuImpulseJointSet; #[cfg(feature = "dim3")] use crate::dynamics::GpuMultibodySet; use crate::math::{Pose, Vector}; use crate::queries::{GpuColliderMaterial, GpuIndexedContact}; -use crate::shaders::queries::MAX_MANIFOLD_POINTS; use crate::shaders::PaddedVector; -use crate::shaders::broad_phase::{CollisionPair, ContactPlan, NarrowPhasePfmPair}; +use crate::shaders::broad_phase::{CollisionPair, ContactPlan, LbvhNode, NarrowPhasePfmPair}; #[cfg(feature = "dim3")] use crate::shaders::dynamics::MbContactIndexEntry; use crate::shaders::dynamics::{ @@ -16,6 +15,7 @@ use crate::shaders::dynamics::{ TwoBodyConstraintBuilder, Velocity as GpuVelocity, WorldMassProperties as GpuWorldMassProperties, }; +use crate::shaders::queries::MAX_MANIFOLD_POINTS; use crate::shaders::shapes::Shape; use crate::shaders::utils::BatchIndices; use crate::utils::{ComputeGraphCache, PrefixSumWorkspace}; @@ -54,6 +54,21 @@ impl DebugContact { } } +/// One node of the broad-phase LBVH, read back by [`RbdState::debug_lbvh`]. +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct DebugLbvhNode { + /// Lower corner of the world-space AABB of the node. + pub mins: Vector, + /// Upper corner of that AABB. + pub maxs: Vector, + /// Depth in the tree of its batch (the root is at depth 0). + pub depth: u32, + /// Whether the node is a leaf, i.e. the AABB of a single collider. + pub leaf: bool, + /// Batch (environment) of the tree of this node. + pub batch: u32, +} + /// Performance statistics collected during a physics simulation step. #[derive(Default, Clone, Debug)] pub struct RunStats { @@ -629,6 +644,60 @@ impl RbdState { result } + /// Whether the broad phase tests all collider pairs instead of using the LBVH. + /// This happens for small scenes; `NEXUS_DISABLE_BF` forces the LBVH. + pub fn uses_brute_force_broad_phase(&self) -> bool { + self.num_active_colliders <= BRUTE_FORCE_MAX_COLLIDERS + && std::env::var("NEXUS_DISABLE_BF").is_err() + } + + /// Debug: reads the LBVH back from the GPU, with the depth of each node. + /// `None` if the brute-force broad phase is used (there is no tree then). Slow. + pub async fn debug_lbvh(&self, backend: &GpuBackend) -> Option> { + let n = self.num_active_colliders as usize; + if self.uses_brute_force_broad_phase() || n == 0 { + return None; + } + let tree = backend + .slow_read_vec::(self.lbvh.tree().buffer()) + .await + .ok()?; + + let stride = 2 * self.num_colliders_per_batch as usize; + let num_internal = n - 1; + let mut result = Vec::with_capacity(2 * n * self.num_batches as usize); + let mut stack = Vec::new(); + + for batch in 0..self.num_batches as usize { + let Some(nodes) = tree.get(batch * stride..batch * stride + 2 * n - 1) else { + break; + }; + stack.clear(); + stack.push((0usize, 0u32)); + // Bounded by the node count, so a broken tree can't loop forever. + for _ in 0..nodes.len() { + let Some((id, depth)) = stack.pop() else { + break; + }; + let Some(node) = nodes.get(id) else { continue }; + let leaf = id >= num_internal; + result.push(DebugLbvhNode { + mins: node.aabb.mins, + maxs: node.aabb.maxs, + depth, + leaf, + batch: batch as u32, + }); + if !leaf { + stack.push((node.right as usize, depth + 1)); + stack.push((node.left as usize, depth + 1)); + } + } + } + + Some(result) + } + /// Debug: read back active contacts as `(collider_a, collider_b, body_a, /// body_b, manifold_len)` tuples (only `len > 0` entries). pub fn debug_contact_pairs(&self, backend: &GpuBackend) -> Vec<(u32, u32, u32, u32, u32)> { diff --git a/src_rbd/pipeline/rbd_state_from_rapier.rs b/src_rbd/pipeline/rbd_state_from_rapier.rs index 42cba53..e0bd6c3 100644 --- a/src_rbd/pipeline/rbd_state_from_rapier.rs +++ b/src_rbd/pipeline/rbd_state_from_rapier.rs @@ -767,11 +767,8 @@ impl RbdState { let new_body_constraint_ids = Tensor::vector_uninit(backend, contacts_capacity * 2, storage).unwrap(); - let lbvh_usages = if crate::VALIDATE_LBVH_TOPOLOGY { - BufferUsages::STORAGE | BufferUsages::COPY_SRC - } else { - BufferUsages::STORAGE - }; + // `COPY_SRC` so the LBVH validation and the debug-renderer can read the tree. + let lbvh_usages = BufferUsages::STORAGE | BufferUsages::COPY_SRC; let contacts_capacity_cpu = contacts_capacity; let collision_pairs_capacity_cpu = pairs_capacity; diff --git a/src_rbd/pipeline/rbd_step.rs b/src_rbd/pipeline/rbd_step.rs index aa7b59a..00c8dd0 100644 --- a/src_rbd/pipeline/rbd_step.rs +++ b/src_rbd/pipeline/rbd_step.rs @@ -1,6 +1,6 @@ //! The [`RbdPipeline`] running one full simulation step on the GPU. -use crate::broad_phase::{BRUTE_FORCE_MAX_COLLIDERS, GpuNarrowPhase, Lbvh}; +use crate::broad_phase::{GpuNarrowPhase, Lbvh}; #[cfg(feature = "dim3")] use crate::dynamics::GpuMultibodySolver; use crate::dynamics::{ @@ -215,8 +215,7 @@ impl RbdPipeline { drop(pass); - let use_bf = state.num_active_colliders <= BRUTE_FORCE_MAX_COLLIDERS - && std::env::var("NEXUS_DISABLE_BF").is_err(); + let use_bf = state.uses_brute_force_broad_phase(); if use_bf { let mut pass = encoder.begin_pass("[RBD] bf-find-pairs", timestamps.as_deref_mut()); self.lbvh.brute_force_pairs( diff --git a/src_viewer/debug_render/backend.rs b/src_viewer/debug_render/backend.rs index 54474b6..1b79d1a 100644 --- a/src_viewer/debug_render/backend.rs +++ b/src_viewer/debug_render/backend.rs @@ -59,3 +59,47 @@ pub fn hsla_to_rgba([h, s, l, a]: DebugColor) -> [f32; 4] { let m = l - c / 2.0; [r + m, g + m, b + m, a] } + +/// Adds the edges of an axis-aligned box: 4 segments in 2D, 12 in 3D. +#[cfg(feature = "dim2")] +pub(super) fn push_box(mins: Vector, maxs: Vector, color: [f32; 4], lines: &mut Vec) { + let corners = [ + mins, + Vector::new(maxs.x, mins.y), + maxs, + Vector::new(mins.x, maxs.y), + ]; + for i in 0..4 { + lines.push(DebugLine { + a: corners[i], + b: corners[(i + 1) % 4], + color, + }); + } +} + +/// Adds the edges of an axis-aligned box: 4 segments in 2D, 12 in 3D. +#[cfg(feature = "dim3")] +pub(super) fn push_box(mins: Vector, maxs: Vector, color: [f32; 4], lines: &mut Vec) { + // Corner `i` takes x/y/z from `maxs` where bit 0/1/2 of `i` is set. + // Two corners share an edge if `i ^ j` has a single bit. + let corner = |i: usize| { + Vector::new( + if i & 1 != 0 { maxs.x } else { mins.x }, + if i & 2 != 0 { maxs.y } else { mins.y }, + if i & 4 != 0 { maxs.z } else { mins.z }, + ) + }; + for i in 0..8usize { + for bit in [1, 2, 4] { + let j = i ^ bit; + if j > i { + lines.push(DebugLine { + a: corner(i), + b: corner(j), + color, + }); + } + } + } +} diff --git a/src_viewer/debug_render/mod.rs b/src_viewer/debug_render/mod.rs index 2b9fd01..c88da65 100644 --- a/src_viewer/debug_render/mod.rs +++ b/src_viewer/debug_render/mod.rs @@ -6,4 +6,4 @@ mod renderer; pub use backend::{DebugLine, DebugPoint, LineCollector, hsla_to_rgba}; pub use mpm::MpmDebugRenderMode; -pub use renderer::{DebugRenderSettings, DebugRenderer}; +pub use renderer::{DebugRenderSettings, DebugRenderer, LbvhStatus}; diff --git a/src_viewer/debug_render/mpm.rs b/src_viewer/debug_render/mpm.rs index 8fa047a..c9f4614 100644 --- a/src_viewer/debug_render/mpm.rs +++ b/src_viewer/debug_render/mpm.rs @@ -1,9 +1,8 @@ //! Debug rendering of the MPM state: the particles and the sparse grid. -use super::backend::{DebugLine, DebugPoint}; +use super::backend::{DebugLine, DebugPoint, push_box}; use bitflags::bitflags; use nexus::mpm::debug::{DebugGrid, DebugParticle}; -use nexus::rbd::math::Vector; bitflags! { /// Which parts of the MPM state to draw (like rapier's `DebugRenderMode`). @@ -132,47 +131,3 @@ pub fn render_grid( } } } - -/// Adds the edges of an axis-aligned box: 4 segments in 2D, 12 in 3D. -#[cfg(feature = "dim2")] -fn push_box(mins: Vector, maxs: Vector, color: [f32; 4], lines: &mut Vec) { - let corners = [ - mins, - Vector::new(maxs.x, mins.y), - maxs, - Vector::new(mins.x, maxs.y), - ]; - for i in 0..4 { - lines.push(DebugLine { - a: corners[i], - b: corners[(i + 1) % 4], - color, - }); - } -} - -/// Adds the edges of an axis-aligned box: 4 segments in 2D, 12 in 3D. -#[cfg(feature = "dim3")] -fn push_box(mins: Vector, maxs: Vector, color: [f32; 4], lines: &mut Vec) { - // Corner `i` takes x/y/z from `maxs` where bit 0/1/2 of `i` is set. - // Two corners share an edge if `i ^ j` has a single bit. - let corner = |i: usize| { - Vector::new( - if i & 1 != 0 { maxs.x } else { mins.x }, - if i & 2 != 0 { maxs.y } else { mins.y }, - if i & 4 != 0 { maxs.z } else { mins.z }, - ) - }; - for i in 0..8usize { - for bit in [1, 2, 4] { - let j = i ^ bit; - if j > i { - lines.push(DebugLine { - a: corner(i), - b: corner(j), - color, - }); - } - } - } -} diff --git a/src_viewer/debug_render/renderer.rs b/src_viewer/debug_render/renderer.rs index fed4ed1..5f42cf6 100644 --- a/src_viewer/debug_render/renderer.rs +++ b/src_viewer/debug_render/renderer.rs @@ -1,6 +1,6 @@ //! The debug renderer: turns the GPU state into debug segments. -use super::backend::{DebugLine, DebugPoint, LineCollector, hsla_to_rgba}; +use super::backend::{DebugLine, DebugPoint, LineCollector, hsla_to_rgba, push_box}; use super::mpm::{MpmDebugRenderMode, render_grid, render_particles}; use crate::rapier::pipeline::{DebugRenderMode, DebugRenderPipeline, DebugRenderStyle}; use crate::rapier::prelude::{ColliderSet, RigidBodyHandle, RigidBodySet}; @@ -30,6 +30,26 @@ pub struct DebugRenderSettings { /// Hide the regular rendering, to only show the debug lines and points. /// Only used if [`Self::enabled`] is set. pub hide_regular_rendering: bool, + /// Draw the LBVH nodes at [`Self::lbvh_depth`]. + pub lbvh: bool, + /// Depth of the LBVH nodes to draw (the root is at depth 0). + /// Leaves that are less deep are drawn too. + pub lbvh_depth: u32, +} + +/// What the last [`DebugRenderer::sync`] found when drawing the LBVH. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum LbvhStatus { + /// Not requested, or nothing to read (debug render off, no rigid-bodies). + #[default] + Off, + /// The scene uses the brute-force broad phase, so there is no tree. + BruteForce, + /// The tree was drawn. Its deepest node is at `max_depth`. + Tree { + /// Depth of the deepest node, over all batches. + max_depth: u32, + }, } impl Default for DebugRenderSettings { @@ -50,6 +70,8 @@ impl Default for DebugRenderSettings { rigid_body_axes_length: style.rigid_body_axes_length, mpm_velocity_scale: 0.1, hide_regular_rendering: false, + lbvh: false, + lbvh_depth: 0, } } } @@ -87,8 +109,13 @@ pub struct DebugRenderer { mirrored_counts: Vec<(usize, usize)>, lines: Vec, points: Vec, + lbvh_status: LbvhStatus, } +/// Colors of the LBVH boxes: internal nodes and leaves. +const LBVH_NODE_COLOR: [f32; 4] = [0.0, 0.45, 0.45, 1.0]; +const LBVH_LEAF_COLOR: [f32; 4] = [0.75, 0.4, 0.0, 1.0]; + impl Default for DebugRenderer { fn default() -> Self { Self { @@ -100,6 +127,7 @@ impl Default for DebugRenderer { mirrored_counts: Vec::new(), lines: Vec::new(), points: Vec::new(), + lbvh_status: LbvhStatus::Off, } } } @@ -115,6 +143,11 @@ impl DebugRenderer { &self.points } + /// What the last [`Self::sync`] found when drawing the LBVH. + pub fn lbvh_status(&self) -> LbvhStatus { + self.lbvh_status + } + /// Drops the pose mirrors (e.g. when switching demo). They are rebuilt by the next sync. pub fn clear_scene(&mut self) { self.mirrors.clear(); @@ -133,11 +166,18 @@ impl DebugRenderer { ) { self.lines.clear(); self.points.clear(); + self.lbvh_status = LbvhStatus::Off; if !settings.enabled { return; } + if let Some(rbd) = state.rbd.as_ref() + && settings.lbvh + { + self.render_lbvh(rbd, backend, settings).await; + } + if let Some(rbd) = state.rbd.as_ref() && !settings.mode.is_empty() { @@ -172,6 +212,33 @@ impl DebugRenderer { } } + /// Draws the LBVH nodes at the selected depth, plus the less deep leaves. + async fn render_lbvh( + &mut self, + rbd: &nexus::rbd::pipeline::RbdState, + backend: &GpuBackend, + settings: &DebugRenderSettings, + ) { + let Some(nodes) = rbd.debug_lbvh(backend).await else { + self.lbvh_status = LbvhStatus::BruteForce; + return; + }; + let max_depth = nodes.iter().map(|n| n.depth).max().unwrap_or(0); + let depth = settings.lbvh_depth.min(max_depth); + for node in nodes + .iter() + .filter(|n| n.depth == depth || (n.leaf && n.depth < depth)) + { + let color = if node.leaf { + LBVH_LEAF_COLOR + } else { + LBVH_NODE_COLOR + }; + push_box(node.mins, node.maxs, color, &mut self.lines); + } + self.lbvh_status = LbvhStatus::Tree { max_depth }; + } + /// Draws the MPM particles and grid, read back from the GPU. async fn render_mpm( &mut self, diff --git a/src_viewer/lib.rs b/src_viewer/lib.rs index 85aab2a..f81f7e6 100644 --- a/src_viewer/lib.rs +++ b/src_viewer/lib.rs @@ -19,7 +19,7 @@ mod ui; pub mod viewer; pub use backend::BackendType; -pub use debug_render::{DebugRenderSettings, DebugRenderer, MpmDebugRenderMode}; +pub use debug_render::{DebugRenderSettings, DebugRenderer, LbvhStatus, MpmDebugRenderMode}; #[cfg(feature = "dim3")] pub use graphics::{RenderMaterial, VisualTexture}; #[cfg(feature = "dim3")] diff --git a/src_viewer/ui.rs b/src_viewer/ui.rs index 2429ce5..9420d69 100644 --- a/src_viewer/ui.rs +++ b/src_viewer/ui.rs @@ -6,7 +6,7 @@ use nexus::state::NexusCounts; use std::time::Duration; use crate::backend::BackendType; -use crate::debug_render::{DebugRenderSettings, MpmDebugRenderMode}; +use crate::debug_render::{DebugRenderSettings, LbvhStatus, MpmDebugRenderMode}; use crate::rapier::pipeline::DebugRenderMode; use egui::{Button, CollapsingHeader, Color32, ComboBox, CornerRadius, RichText, Stroke}; @@ -163,7 +163,7 @@ pub fn main_panel(ctx: &egui::Context, state: &mut UiState, gpu_available: bool) } Some(UiSection::DebugRender) => { ui.separator(); - debug_render_settings(ui, &mut state.debug_render); + debug_render_settings(ui, &mut state.debug_render, state.debug_lbvh); } _ => {} }); @@ -241,7 +241,7 @@ fn simulation_settings(ui: &mut egui::Ui, state: &mut UiState) { } /// Debug-renderer controls: what to draw, and how. -fn debug_render_settings(ui: &mut egui::Ui, s: &mut DebugRenderSettings) { +fn debug_render_settings(ui: &mut egui::Ui, s: &mut DebugRenderSettings, lbvh: LbvhStatus) { ui.label(RichText::new("Debug render").strong()); ui.add_space(2.0); ui.checkbox(&mut s.enabled, "Enabled").on_hover_text( @@ -307,6 +307,11 @@ fn debug_render_settings(ui: &mut egui::Ui, s: &mut DebugRenderSettings) { "The points the contact constraints actually act on (the midpoint \ of each contact pair)", ); + ui.checkbox(&mut s.lbvh, "LBVH") + .on_hover_text("Bounding boxes of the broad-phase BVH nodes, one depth at a time"); + if s.lbvh { + ui.indent("lbvh_depth", |ui| lbvh_depth_control(ui, s, lbvh)); + } ui.add_space(4.0); ui.label("MPM"); @@ -379,6 +384,42 @@ fn debug_render_settings(ui: &mut egui::Ui, s: &mut DebugRenderSettings) { .on_hover_text("Seconds of travel an MPM velocity segment stands for"); } +/// The `- depth +` selector of the LBVH depth, or why there is no tree. +fn lbvh_depth_control(ui: &mut egui::Ui, s: &mut DebugRenderSettings, lbvh: LbvhStatus) { + match lbvh { + LbvhStatus::Tree { max_depth } => { + s.lbvh_depth = s.lbvh_depth.min(max_depth); + ui.horizontal(|ui| { + if ui + .add_enabled(s.lbvh_depth > 0, Button::new("-")) + .on_hover_text("Shallower") + .clicked() + { + s.lbvh_depth -= 1; + } + ui.add(egui::DragValue::new(&mut s.lbvh_depth).range(0..=max_depth)); + if ui + .add_enabled(s.lbvh_depth < max_depth, Button::new("+")) + .on_hover_text("Deeper") + .clicked() + { + s.lbvh_depth += 1; + } + ui.label(format!("depth (max {max_depth})")); + }); + } + LbvhStatus::BruteForce => { + ui.label( + "No tree: scenes with at most 64 colliders use the brute-force \ + broad phase (set NEXUS_DISABLE_BF to force the LBVH).", + ); + } + LbvhStatus::Off => { + ui.label("No tree to show: the scene has no rigid bodies."); + } + } +} + /// A labelled per-component drag editor for a gravity vector (2D or 3D). fn gravity_drag(ui: &mut egui::Ui, label: &str, g: &mut nexus::rbd::math::Vector) { ui.horizontal(|ui| { diff --git a/src_viewer/viewer.rs b/src_viewer/viewer.rs index ee9bcb2..232d5fc 100644 --- a/src_viewer/viewer.rs +++ b/src_viewer/viewer.rs @@ -53,7 +53,7 @@ use nexus::state::{NexusCounts, NexusState}; use rapier::prelude::{RigidBodyHandle, SharedShape}; use crate::backend::BackendType; -use crate::debug_render::{DebugRenderSettings, DebugRenderer}; +use crate::debug_render::{DebugRenderSettings, DebugRenderer, LbvhStatus}; use crate::graphics::RenderContext; #[cfg(feature = "dim3")] use crate::graphics::VisualTexture; @@ -136,6 +136,8 @@ pub struct UiState { /// Debug rendering of the physics state. Off by default: it reads the /// state back from the GPU every frame. pub debug_render: DebugRenderSettings, + /// What the debug renderer found when drawing the LBVH (for the UI). + pub(crate) debug_lbvh: LbvhStatus, } /// Editable simulation settings exposed in the viewer UI. The viewer pulls @@ -430,6 +432,7 @@ impl NexusViewer { counts: NexusCounts::default(), mpm_render_mode: MpmRenderMode::default(), debug_render: DebugRenderSettings::default(), + debug_lbvh: LbvhStatus::Off, }, }; @@ -1387,6 +1390,7 @@ impl NexusViewer { self.debug_renderer .sync(state, &debug_backend, &debug_settings) .await; + self.ui.debug_lbvh = self.debug_renderer.lbvh_status(); self.sync_timestamps(timestamps).await; // `pipeline.step` overwrites `run_stats` (with empty pass timings) every From 6d587a9d0ac4dd44103c459fe68c522ba65eaa06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Sun, 27 Sep 2026 18:59:29 +0200 Subject: [PATCH 7/7] feat(viewer): keep the previous/next demo buttons visible in every tab --- src_viewer/ui.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src_viewer/ui.rs b/src_viewer/ui.rs index 9420d69..29a1e8f 100644 --- a/src_viewer/ui.rs +++ b/src_viewer/ui.rs @@ -170,6 +170,11 @@ pub fn main_panel(ctx: &egui::Context, state: &mut UiState, gpu_available: bool) ui.separator(); + // Demo navigation, shown for every tab. + if !state.demos.is_empty() { + demo_navigation(ui, state); + } + // Bottom controls. ui.horizontal(|ui| { let (play_label, play_hover) = if state.run_state == RunState::Running { @@ -534,9 +539,9 @@ impl UiState { } } -fn examples_section(ui: &mut egui::Ui, state: &mut UiState) { - // Previous/Next navigation + current demo name. Navigation follows the - // grouped listing order (see `demo_display_order`), not the raw index. +/// Previous/Next buttons and the name of the current demo. +/// The order is the one of the demo list (see `demo_display_order`). +fn demo_navigation(ui: &mut egui::Ui, state: &mut UiState) { let order = state.demo_display_order(); let pos = order .iter() @@ -568,11 +573,9 @@ fn examples_section(ui: &mut egui::Ui, state: &mut UiState) { .italics(), ); }); +} - ui.add_space(4.0); - ui.separator(); - ui.add_space(4.0); - +fn examples_section(ui: &mut egui::Ui, state: &mut UiState) { demo_group(ui, state, DemoKind::Rbd, "Rigid Bodies"); demo_group(ui, state, DemoKind::Mpm, "MPM"); }