perf(scene): multi-resolution mesh LOD (Phase 3.4)
ACIS SAT entities (Solid3D / Region / Body) were tessellated at one
fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless
of how far they were from the camera. Distant solids paid the full
triangle bill for fragments that mostly resolved to sub-pixel size.
Generate three pre-built LODs per entity at load time:
* LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag.
* LOD 1 (MID) — 24/16/8. Use 50–200 px.
* LOD 2 (LOW) — 12/8/4. Use below 50 px.
Tessellation cost is paid 3× on load (cheap — typical files have few
solids). Render picks per-frame via `compute_mesh_lod` from each mesh's
`world_aabb` projected diagonal. When a level is missing (e.g. a solid
entity that only emits LOD 0), the selector walks down to the nearest
available slot.
API ripples:
* New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`,
`tess_sphere_face`, `tess_torus_face`.
* `tessellate_solid3d / _region / _body` now return `MeshLodSet`.
* `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single`
constructor so interactive truck-based commands (BOX/CYLINDER/etc.)
that only generate one tessellation stay one-line.
* GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and
a `mesh_lod_levels` per-frame index vector.
* STL/STEP export pulls slot 0 explicitly so the on-disk geometry
isn't downgraded by the view-dependent ladder.
Background-thread lazy generation (per the roadmap) is deferred — the
synchronous eager build is fine for typical CAD files. Switch to a
queue + background tessellator when files with hundreds of solids
become a load-time bottleneck.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
e7675be0bc
commit
8c08ae104a
8 changed files with 321 additions and 59 deletions
|
|
@ -7,7 +7,7 @@
|
|||
// ------
|
||||
// 40 B / vertex
|
||||
|
||||
use crate::scene::mesh_model::MeshModel;
|
||||
use crate::scene::mesh_model::{MeshLodSet, MeshModel};
|
||||
use iced::wgpu;
|
||||
use iced::wgpu::util::DeviceExt;
|
||||
|
||||
|
|
@ -55,6 +55,27 @@ pub struct MeshGpu {
|
|||
pub index_count: u32,
|
||||
}
|
||||
|
||||
/// GPU-side bundle of MeshLodSet — one MeshGpu per available LOD plus
|
||||
/// the world-XY AABB needed to pick a level per frame.
|
||||
pub struct MeshLodGpu {
|
||||
pub lods: Vec<MeshGpu>,
|
||||
pub world_aabb: [f32; 4],
|
||||
}
|
||||
|
||||
impl MeshLodGpu {
|
||||
pub fn new(device: &wgpu::Device, set: &MeshLodSet) -> Self {
|
||||
Self {
|
||||
lods: set
|
||||
.lods
|
||||
.iter()
|
||||
.filter(|m| !m.indices.is_empty())
|
||||
.map(|m| MeshGpu::new(device, m))
|
||||
.collect(),
|
||||
world_aabb: set.world_aabb,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MeshGpu {
|
||||
pub fn new(device: &wgpu::Device, mesh: &MeshModel) -> Self {
|
||||
let has_normals = mesh.normals.len() == mesh.verts.len();
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@ use iced::{Rectangle, Size};
|
|||
pub use face3d_gpu::Face3DGpu;
|
||||
pub use hatch_gpu::HatchGpu;
|
||||
pub use image_gpu::ImageGpu;
|
||||
pub use mesh_gpu::MeshGpu;
|
||||
pub use mesh_gpu::MeshLodGpu;
|
||||
pub use uniforms::Uniforms;
|
||||
pub use viewcube::ViewCubePipeline;
|
||||
pub use wire_gpu::WireGpu;
|
||||
|
||||
use crate::scene::hatch_model::HatchModel;
|
||||
use crate::scene::image_model::ImageModel;
|
||||
use crate::scene::mesh_model::MeshModel;
|
||||
use crate::scene::mesh_model::MeshLodSet;
|
||||
use crate::scene::wire_model::WireModel;
|
||||
|
||||
/// MSAA sample count for the main drawing pipelines.
|
||||
|
|
@ -70,7 +70,11 @@ pub struct Pipeline {
|
|||
gpu_images: Vec<ImageGpu>,
|
||||
/// Pixel scissor rects [x, y, w, h] for viewport-clipped images. Recomputed each frame.
|
||||
image_pixel_scissors: Vec<Option<[u32; 4]>>,
|
||||
gpu_meshes: Vec<MeshGpu>,
|
||||
gpu_meshes: Vec<MeshLodGpu>,
|
||||
/// Per-mesh LOD level (0=high, 1=mid, 2=low) picked each frame from
|
||||
/// the projected pixel diagonal. Mirrors `hatch_pixel_scissors` —
|
||||
/// recomputed in `compute_mesh_lod`.
|
||||
mesh_lod_levels: Vec<usize>,
|
||||
/// Batched 3DFACE fill (all faces in one buffer) and edges (merged wire).
|
||||
gpu_face3d_fill: Option<Face3DGpu>,
|
||||
gpu_face3d_edges: Vec<WireGpu>,
|
||||
|
|
@ -607,6 +611,7 @@ impl Pipeline {
|
|||
gpu_images: vec![],
|
||||
image_pixel_scissors: vec![],
|
||||
gpu_meshes: vec![],
|
||||
mesh_lod_levels: vec![],
|
||||
gpu_face3d_fill: None,
|
||||
gpu_face3d_edges: vec![],
|
||||
viewcube,
|
||||
|
|
@ -697,11 +702,23 @@ impl Pipeline {
|
|||
self.gpu_face3d_edges = WireGpu::from_batch(device, face3d_wires);
|
||||
}
|
||||
|
||||
pub fn upload_meshes(&mut self, device: &wgpu::Device, meshes: &[MeshModel]) {
|
||||
pub fn upload_meshes(&mut self, device: &wgpu::Device, meshes: &[MeshLodSet]) {
|
||||
self.gpu_meshes = meshes
|
||||
.iter()
|
||||
.filter(|m| !m.indices.is_empty())
|
||||
.map(|m| MeshGpu::new(device, m))
|
||||
.filter(|s| s.lods.iter().any(|m| !m.indices.is_empty()))
|
||||
.map(|s| MeshLodGpu::new(device, s))
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Per-frame mesh LOD selector. Picks slot 0/1/2 based on the
|
||||
/// projected pixel diagonal of each mesh's `world_aabb` (Phase 3.4
|
||||
/// ladder: >200 px → 0, 50–200 → 1, <50 → 2). Falls back to the
|
||||
/// nearest available lower slot when a level wasn't generated.
|
||||
pub fn compute_mesh_lod(&mut self, view_proj: glam::Mat4, clip_w: u32, clip_h: u32) {
|
||||
self.mesh_lod_levels = self
|
||||
.gpu_meshes
|
||||
.iter()
|
||||
.map(|m| pick_mesh_lod(m, view_proj, clip_w, clip_h))
|
||||
.collect();
|
||||
}
|
||||
|
||||
|
|
@ -887,12 +904,22 @@ impl Pipeline {
|
|||
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
|
||||
pass.set_pipeline(&self.mesh_pipeline);
|
||||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||||
for mesh in &self.gpu_meshes {
|
||||
if mesh.index_count > 0 {
|
||||
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
|
||||
pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..mesh.index_count, 0, 0..1);
|
||||
for (i, set) in self.gpu_meshes.iter().enumerate() {
|
||||
let level = self
|
||||
.mesh_lod_levels
|
||||
.get(i)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.min(set.lods.len().saturating_sub(1));
|
||||
let Some(mesh) = set.lods.get(level) else {
|
||||
continue;
|
||||
};
|
||||
if mesh.index_count == 0 {
|
||||
continue;
|
||||
}
|
||||
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
|
||||
pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..mesh.index_count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1184,6 +1211,70 @@ impl Pipeline {
|
|||
}
|
||||
}
|
||||
|
||||
/// Project a mesh's `world_aabb` and pick a LOD slot:
|
||||
/// diagonal > 200 px → 0 (HIGH)
|
||||
/// diagonal 50–200 px → 1 (MID)
|
||||
/// diagonal < 50 px → 2 (LOW)
|
||||
/// When the picked slot is missing from `gpu_meshes[i].lods`, walks down
|
||||
/// to the next available coarser/finer level so the mesh always renders.
|
||||
fn pick_mesh_lod(
|
||||
mesh: &MeshLodGpu,
|
||||
view_proj: glam::Mat4,
|
||||
clip_w: u32,
|
||||
clip_h: u32,
|
||||
) -> usize {
|
||||
let diag_px = aabb_diagonal_pixels(mesh.world_aabb, view_proj, clip_w, clip_h);
|
||||
let target = if diag_px > 200.0 {
|
||||
0
|
||||
} else if diag_px > 50.0 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
// Walk down to nearest available LOD (some entities won't have all 3).
|
||||
for level in (0..=target).rev() {
|
||||
if mesh.lods.get(level).is_some() {
|
||||
return level;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
fn aabb_diagonal_pixels(
|
||||
aabb: [f32; 4],
|
||||
view_proj: glam::Mat4,
|
||||
clip_w: u32,
|
||||
clip_h: u32,
|
||||
) -> f32 {
|
||||
let [x0, y0, x1, y1] = aabb;
|
||||
if !x0.is_finite() || !y0.is_finite() || !x1.is_finite() || !y1.is_finite() {
|
||||
return f32::INFINITY;
|
||||
}
|
||||
let w = clip_w as f32;
|
||||
let h = clip_h as f32;
|
||||
let corners = [
|
||||
view_proj.project_point3(glam::Vec3::new(x0, y0, 0.0)),
|
||||
view_proj.project_point3(glam::Vec3::new(x1, y0, 0.0)),
|
||||
view_proj.project_point3(glam::Vec3::new(x0, y1, 0.0)),
|
||||
view_proj.project_point3(glam::Vec3::new(x1, y1, 0.0)),
|
||||
];
|
||||
let mut min_px = f32::INFINITY;
|
||||
let mut max_px = f32::NEG_INFINITY;
|
||||
let mut min_py = f32::INFINITY;
|
||||
let mut max_py = f32::NEG_INFINITY;
|
||||
for c in &corners {
|
||||
let px = (c.x + 1.0) * 0.5 * w;
|
||||
let py = (1.0 - c.y) * 0.5 * h;
|
||||
if px < min_px { min_px = px; }
|
||||
if px > max_px { max_px = px; }
|
||||
if py < min_py { min_py = py; }
|
||||
if py > max_py { max_py = py; }
|
||||
}
|
||||
let dx = max_px - min_px;
|
||||
let dy = max_py - min_py;
|
||||
(dx * dx + dy * dy).sqrt()
|
||||
}
|
||||
|
||||
/// Return `true` when the world-XY AABB's screen-space size is below the
|
||||
/// given pixel threshold. Used by LOD passes (hatch skip, etc.) to drop
|
||||
/// draw calls that wouldn't contribute a visible pixel.
|
||||
|
|
|
|||
Loading…
Reference in a new issue