* 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:
1 parent
8b97b9ed62
commit
d43c414b7b
7 files changed
+805
-14
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user