View-dependent display refinement: finer curved faces and edges as you zoom in (#1320) (#1425)

* View-dependent display refinement: bodies seen up close are drawn from finer display plans (#1320)

Each frame the viewport works out every shown body's screen-space error (the display
quality's chord tolerance in pixels where the body is nearest the eye) and picks a
refinement step past the chosen display quality, each halving the chord, with hysteresis.
Steps are planned off the UI thread and swapped in when ready, within a triangle budget
and a cache size. Faces (GPU plan or CPU mesh), edges, silhouettes and highlights of a
refined body all come from the refined plan, so a highlight stays on the drawn triangles.
Planned bodies are chunked by content on the GPU, so a refinement re-evaluates only its
own chunk. Display only: the reference mesh, the B-rep and exports never change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Display refinement: only collect finished plans while the view stands still, time the choice, keep the web planning one step a frame

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Format

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep the Display quality note as it was (the longer one pushed the Colour vision list off the Graphics page)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-15 11:03:53 -04:00
1 parent 8b97b9ed62
commit d43c414b7b
7 files changed
+805 -14

No files matched your search

+105
View File
@@ -77,6 +77,57 @@ impl DisplayQuality {
}
}
/// Most refinement steps view-dependent display refinement takes past the chosen level (#1320):
/// each halves the chord tolerance, so the finest is 1/16 of the chosen level's.
pub const MAX_REFINE: u8 = 4;
/// Screen-space error (physical pixels) above which a body is refined: a facet's sag shows.
pub const REFINE_PX: f64 = 1.0;
/// Error a refinement aims for (below [`REFINE_PX`], so a body just refined is not refined again
/// at the next small zoom step).
pub const TARGET_PX: f64 = 0.75;
/// Error below which a refined body steps back toward the chosen level. A refinement lands
/// between half [`TARGET_PX`] and [`TARGET_PX`], and a step back between this and twice this, so
/// zooming out or in by up to 2× from either never flips the level back.
pub const COARSEN_PX: f64 = 0.175;
impl DisplayQuality {
/// Chord tolerance (mm) of a body of this size refined `k` steps past this level: halved
/// per step.
pub fn refined_chord(self, size: f64, k: u8) -> f64 {
self.chord(size) / f64::from(1u32 << k.min(MAX_REFINE))
}
/// Facet angle (radians) `k` steps past this level: an arc's sag goes with the square of its
/// angle, so the angle shrinks by √2 per step, keeping pace with the chord.
pub fn refined_angle(self, k: u8) -> f64 {
self.angle() / std::f64::consts::SQRT_2.powi(i32::from(k.min(MAX_REFINE)))
}
}
/// The refinement step a body should be drawn at (0: the chosen level), from `err_px`: the
/// screen-space error of the chosen level, its chord tolerance in physical pixels where the body
/// is nearest the eye (0 for a body off screen or too small to matter). `current` is the step
/// drawn now; steps halve the error. A body is refined once its error passes [`REFINE_PX`], to the
/// first step under [`TARGET_PX`], and steps back only once the error falls under [`COARSEN_PX`],
/// to the coarsest step that stays under twice that: the band between keeps an orbit or a small
/// zoom from flipping levels. Never past `max` (at most [`MAX_REFINE`]).
pub fn refinement_for(err_px: f64, current: u8, max: u8) -> u8 {
let max = max.min(MAX_REFINE);
let current = current.min(max);
if !(err_px.is_finite() && err_px > 0.0) {
return 0;
}
let err = |k: u8| err_px / f64::from(1u32 << k);
if err(current) > REFINE_PX {
(current..=max).find(|&k| err(k) <= TARGET_PX).unwrap_or(max)
} else if err(current) < COARSEN_PX {
(0..=current).find(|&k| err(k) <= 2.0 * COARSEN_PX).unwrap_or(current)
} else {
current
}
}
static CURRENT: AtomicU8 = AtomicU8::new(1);
/// The level the viewport draws at (process-wide; set from the preferences).
@@ -140,4 +191,58 @@ mod tests {
plan.vertex_count()
});
}
#[test]
fn refinement_follows_the_screen_space_error_with_hysteresis() {
// Off screen or tiny: the chosen level.
assert_eq!(refinement_for(0.0, 3, MAX_REFINE), 0);
assert_eq!(refinement_for(f64::NAN, 2, MAX_REFINE), 0);
// Fine already: stays.
assert_eq!(refinement_for(0.6, 0, MAX_REFINE), 0);
assert_eq!(refinement_for(1.0, 0, MAX_REFINE), 0);
// Past a pixel: the first step under the target (each halves the error).
assert_eq!(refinement_for(1.2, 0, MAX_REFINE), 1);
assert_eq!(refinement_for(5.0, 0, MAX_REFINE), 3);
assert_eq!(refinement_for(5.0, 1, MAX_REFINE), 3);
// Capped.
assert_eq!(refinement_for(1e6, 0, MAX_REFINE), MAX_REFINE);
assert_eq!(refinement_for(1e6, 0, 2), 2);
assert_eq!(refinement_for(1e6, 9, 99), MAX_REFINE);
// Zooming back out: kept until the error is well under a pixel, then the coarsest step
// that stays under half a pixel.
assert_eq!(refinement_for(5.0, 3, MAX_REFINE), 3);
assert_eq!(refinement_for(2.5, 3, MAX_REFINE), 3, "0.31 px at step 3: in the band");
assert_eq!(refinement_for(1.2, 3, MAX_REFINE), 2, "0.15 px at step 3: down to 0.3 px");
assert_eq!(refinement_for(0.05, 3, MAX_REFINE), 0);
// No flicker: any error, once settled, gives the same step again, and a zoom by up to
// 2x either way from where a level was chosen keeps it.
let mut e = 0.01;
while e < 1e4 {
for start in 0..=MAX_REFINE {
let k = refinement_for(e, start, MAX_REFINE);
assert_eq!(refinement_for(e, k, MAX_REFINE), k, "error {e}, from {start}");
// Settled by zooming in (from the chosen level) or out (from the finest).
if start == 0 || start == MAX_REFINE {
for f in [0.5, 0.7, 1.0, 1.4, 2.0] {
let back = refinement_for(e * f, k, MAX_REFINE);
let zoomed_in = f >= 1.0;
assert!(
back == k || (zoomed_in && back > k && start == 0) || (!zoomed_in && back < k && start == MAX_REFINE),
"error {e} x{f} from {start}: {k} -> {back}"
);
}
}
}
e *= 1.13;
}
}
#[test]
fn refined_steps_halve_the_chord() {
let q = DisplayQuality::Normal;
assert_eq!(q.refined_chord(200.0, 0), q.chord(200.0));
assert_eq!(q.refined_chord(200.0, 2), q.chord(200.0) / 4.0);
assert_eq!(q.refined_chord(200.0, 99), q.chord(200.0) / 16.0);
assert!((q.refined_angle(2) - q.angle() / 2.0).abs() < 1e-12);
}
}
+19
View File
@@ -126,6 +126,25 @@ impl ModelBody {
.clone()
}
/// The display plan and its mesh `k` steps finer than `q` (view-dependent refinement, #1320;
/// see [`crate::display_quality::refinement_for`]). Step 0 is the cached plan at `q`; finer
/// steps are made on each call and kept by the viewport, which evicts them when it runs over
/// its budget. `None` when the body has no plan at that step.
pub fn refined_display(&self, q: crate::display_quality::DisplayQuality, k: u8) -> Option<(Arc<TessPlan>, Arc<Mesh>)> {
if k == 0 {
return Some((self.display_plan_at(q)?, self.plan_mesh_at(q)?));
}
let plan = match self.body.display_plan_angular(q.refined_chord(self.body.size(), k), q.refined_angle(k)) {
Ok(plan) => plan,
Err(e) => {
log::debug!("{}: no display plan refined {k} steps ({e})", self.name);
return None;
}
};
let mesh = self.body.display_mesh_of(&plan).ok()?;
Some((Arc::new(plan), Arc::new(mesh)))
}
/// The display mesh's chord tolerance: 1/1000 of the body's size.
fn display_tol(&self) -> f64 {
(self.body.size() * 1e-3).max(1e-3)