perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models

Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids)
was effectively frozen. Several independent costs, all addressed here:

- Face3D edge/fill buffers were re-uploaded on every camera move (keyed on
  camera_generation). They are world-space and selection-independent, so gate
  them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every
  wire.
- Solid meshes were drawn one buffer + one draw call per solid (~10k draws/
  frame), which strangled the GPU front end. Concatenate every solid's LOD0
  into a few large buffers (split only to stay under the 256 MB buffer cap) and
  draw the whole set in a handful of calls. Built once per geometry epoch, so
  selection / hover never re-pack it.
- Selection / hover highlight is now a small tinted overlay of just the picked
  solids, drawn last with an Always-depth pipeline so it shows on top even when
  the solid is occluded — instead of re-tinting and re-uploading all meshes.
- Hover / pick hit-testing scanned every triangle of every solid each move
  (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase
  (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh
  set, so picking is O(solids) cheap projections plus the few solids actually
  under the cursor.
- Block-internal hatch hit-testing exploded every INSERT on every hover.
  Cache the result per geometry epoch and skip blocks that contain no hatch.
- Curved-surface (cone/sphere/torus) tessellation tolerance is now radius-
  relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and
  matches the circle/arc wire tessellation, instead of exploding on large radii.

Also adds docs/tessellation.md mapping every EntityType to its tessellation
path (truck B-rep vs truck curve topology vs direct).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-30 22:46:29 +03:00
1 parent 27896e3401
commit a994a148fe
10 files changed
+572 -146

No files matched your search

+100
View File
@@ -65,7 +65,9 @@ pub struct MeshGpu {
/// `(a,b)(b,c)(c,a)`. Used by the wireframe-mode render path so 3D
/// solids draw as their triangle edges without needing the
/// `POLYGON_MODE_LINE` device feature.
#[allow(dead_code)] // only the highlight overlay builds MeshGpu now (fill only)
pub wire_index_buffer: wgpu::Buffer,
#[allow(dead_code)]
pub wire_index_count: u32,
}
@@ -79,6 +81,7 @@ pub struct MeshLodGpu {
/// How a solid mesh is highlighted this frame.
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum Highlight {
#[allow(dead_code)] // the highlight overlay only builds Selected / Hover
None,
/// Hovered — light orange wash.
Hover,
@@ -97,7 +100,104 @@ impl Highlight {
}
}
// ── Batched mesh buffers ──────────────────────────────────────────────────
//
// One MeshGpu per solid means one vertex/index bind + draw call per solid —
// ~10k draw calls a frame on a heavy 3D model, which strangles the GPU front
// end. The batch concatenates every solid's LOD0 geometry into a handful of
// large buffers (split only to stay under the 256 MB per-buffer cap), so the
// whole mesh set draws in a few calls. Vertices already carry their own colour,
// so no per-mesh state is needed between draws. Built once per geometry epoch —
// selection/hover no longer rebuild it (that tint is dropped in the batch path).
/// Cap on vertices per batch buffer: 40 B/vertex × 6 M ≈ 240 MB, under the
/// default 256 MB `max_buffer_size`. Solids beyond the cap spill into the next
/// chunk.
const BATCH_MAX_VERTS: usize = 6_000_000;
pub struct MeshBatchChunk {
pub vertex_buffer: wgpu::Buffer,
pub index_buffer: wgpu::Buffer,
pub index_count: u32,
pub wire_index_buffer: wgpu::Buffer,
pub wire_index_count: u32,
}
fn make_chunk(
device: &wgpu::Device,
verts: &[MeshVertex],
indices: &[u32],
wire_indices: &[u32],
) -> MeshBatchChunk {
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.batch.vbuf"),
contents: bytemuck::cast_slice(verts),
usage: wgpu::BufferUsages::VERTEX,
});
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.batch.ibuf"),
contents: bytemuck::cast_slice(indices),
usage: wgpu::BufferUsages::INDEX,
});
let wire_index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.batch.wire_ibuf"),
contents: bytemuck::cast_slice(wire_indices),
usage: wgpu::BufferUsages::INDEX,
});
MeshBatchChunk {
vertex_buffer,
index_buffer,
index_count: indices.len() as u32,
wire_index_buffer,
wire_index_count: wire_indices.len() as u32,
}
}
/// Concatenate every set's first non-empty LOD into a few large GPU buffers.
/// Returns the chunks plus the total triangle count drawn (for diagnostics).
pub fn build_mesh_batch(device: &wgpu::Device, sets: &[MeshLodSet]) -> (Vec<MeshBatchChunk>, u64) {
let mut chunks = Vec::new();
let mut verts: Vec<MeshVertex> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
let mut wire_indices: Vec<u32> = Vec::new();
let mut total_tris = 0u64;
for set in sets {
let Some(mesh) = set.lods.iter().find(|m| !m.indices.is_empty()) else {
continue;
};
if !verts.is_empty() && verts.len() + mesh.verts.len() > BATCH_MAX_VERTS {
chunks.push(make_chunk(device, &verts, &indices, &wire_indices));
verts.clear();
indices.clear();
wire_indices.clear();
}
let base = verts.len() as u32;
let has_normals = mesh.normals.len() == mesh.verts.len();
for (i, &pos) in mesh.verts.iter().enumerate() {
verts.push(MeshVertex {
position: pos,
normal: if has_normals { mesh.normals[i] } else { [0.0, 1.0, 0.0] },
color: mesh.color,
position_low: mesh.verts_low.get(i).copied().unwrap_or([0.0; 3]),
});
}
for &idx in &mesh.indices {
indices.push(base + idx);
}
for tri in mesh.indices.chunks_exact(3) {
let (a, b, c) = (base + tri[0], base + tri[1], base + tri[2]);
wire_indices.extend_from_slice(&[a, b, b, c, c, a]);
}
total_tris += (mesh.indices.len() / 3) as u64;
}
if !indices.is_empty() {
chunks.push(make_chunk(device, &verts, &indices, &wire_indices));
}
(chunks, total_tris)
}
impl MeshLodGpu {
#[allow(dead_code)] // built by the bypassed per-mesh upload_meshes path
pub fn new(device: &wgpu::Device, set: &MeshLodSet, highlight: Highlight) -> Self {
Self {
lods: set
+155 -73
View File
@@ -45,6 +45,7 @@ pub struct Pipeline {
hatch_batched_pipeline: Option<wgpu::RenderPipeline>,
image_pipeline: wgpu::RenderPipeline,
mesh_pipeline: wgpu::RenderPipeline,
mesh_highlight_pipeline: wgpu::RenderPipeline,
/// Wireframe variant of the mesh pipeline (LineList topology, same
/// vertex layout / shader). Used when the active render mode is
/// Wireframe 2D or Wireframe 3D so 3D solids draw as their
@@ -109,6 +110,19 @@ pub struct Pipeline {
/// Pixel scissor rects [x, y, w, h] for viewport-clipped images. Recomputed each frame.
image_pixel_scissors: Vec<Option<[u32; 4]>>,
gpu_meshes: Vec<MeshLodGpu>,
/// Batched mesh geometry — every solid's LOD0 concatenated into a few large
/// buffers so the whole set draws in a handful of calls instead of one per
/// solid. Rebuilt only when the geometry epoch changes (see
/// `cached_mesh_batch_epoch`), so hover / selection never re-pack it.
gpu_mesh_batch: Vec<mesh_gpu::MeshBatchChunk>,
/// `geometry_epoch` the batch was built for. `u64::MAX` = not built yet.
pub cached_mesh_batch_epoch: u64,
/// Tinted copies of just the selected / hovered solids, drawn over the
/// static base batch so highlight shows without re-packing the whole set.
/// Rebuilt only when the selection/hover or geometry changes — O(highlighted).
gpu_mesh_highlight: Vec<mesh_gpu::MeshGpu>,
/// `(geometry_epoch, selection_generation)` the highlight overlay was built for.
pub cached_highlight_key: (u64, u64),
/// 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`.
@@ -143,7 +157,15 @@ pub struct Pipeline {
/// `(geometry_epoch, selection_generation)` the resident mesh buffers were
/// uploaded for. Bumps on a selection/hover change so highlighted solids
/// re-upload with their tint.
#[allow(dead_code)] // per-mesh LOD path, bypassed by the batched mesh draw
pub cached_mesh_key: (u64, u64),
/// `(geometry_epoch, face3d_fill_active)` the Face3D edge/fill buffers were
/// uploaded for. The buffers are world-space and selection-independent, so
/// they only change when the geometry changes or the 3D-fill mode toggles —
/// never on a pan/orbit. Keyed separately from `cached_epoch` (which carries
/// `camera_generation`) so a camera move no longer re-walks every wire to
/// rebuild the Face3D fill buffer.
pub cached_face3d_key: (u64, bool),
/// Handle → indices into the resident wire set, built once per wire upload
/// (when `cached_wire_id` changes). Lets the selection/hover xray overlay
/// gather just the highlighted entity's wires (`O(highlighted)`) instead of
@@ -510,6 +532,50 @@ impl Pipeline {
cache: None,
});
// Highlight variant — same shader / vertex layout, but `depth_compare =
// Always` and no depth write, so a hovered / selected solid's tint is
// drawn on top of everything (it shows through occluding geometry) and
// doesn't disturb the depth buffer for later passes.
let mesh_highlight_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mesh.highlight.pipeline"),
layout: Some(&mesh_layout),
vertex: wgpu::VertexState {
module: &mesh_shader,
entry_point: Some("vs_main"),
buffers: &[mesh_gpu::MeshVertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::Always,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &mesh_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
// Wireframe variant — same shader / vertex layout / depth state,
// only the input topology changes (LineList) and back-face
// culling drops out (each triangle edge is shared between two
@@ -937,6 +1003,7 @@ impl Pipeline {
hatch_batched_pipeline,
image_pipeline,
mesh_pipeline,
mesh_highlight_pipeline,
mesh_wireframe_pipeline,
mesh_depth_pipeline,
face3d_pipeline,
@@ -967,6 +1034,10 @@ impl Pipeline {
gpu_images: vec![],
image_pixel_scissors: vec![],
gpu_meshes: vec![],
gpu_mesh_batch: vec![],
cached_mesh_batch_epoch: u64::MAX,
gpu_mesh_highlight: vec![],
cached_highlight_key: (u64::MAX, u64::MAX),
mesh_lod_levels: vec![],
mesh_visible: vec![],
gpu_face3d_fill: None,
@@ -976,6 +1047,7 @@ impl Pipeline {
cached_wire_id: u64::MAX,
cached_selection: (u64::MAX, u64::MAX),
cached_mesh_key: (u64::MAX, u64::MAX),
cached_face3d_key: (u64::MAX, false),
wire_handle_index: rustc_hash::FxHashMap::default(),
}
}
@@ -1194,6 +1266,7 @@ impl Pipeline {
}
}
#[allow(dead_code)] // per-mesh upload path, bypassed by upload_mesh_batch
pub fn upload_meshes(
&mut self,
device: &wgpu::Device,
@@ -1222,6 +1295,46 @@ impl Pipeline {
.collect();
}
/// Build the batched mesh buffers (a few large buffers for the whole solid
/// set, drawn in a handful of calls). Selection/hover tint is intentionally
/// not applied here — the batch is geometry-only and stays resident across
/// camera moves and pick changes.
pub fn upload_mesh_batch(&mut self, device: &wgpu::Device, meshes: &[MeshLodSet]) {
let (chunks, _tris) = mesh_gpu::build_mesh_batch(device, meshes);
self.gpu_mesh_batch = chunks;
}
/// Build tinted copies of just the selected / hovered solids. Drawn over the
/// static base batch with the LessEqual mesh pipeline so the tint shows on
/// top at the same depth. O(highlighted), so a pick never touches the rest.
pub fn upload_mesh_highlight(
&mut self,
device: &wgpu::Device,
meshes: &[MeshLodSet],
selected: &rustc_hash::FxHashSet<acadrust::Handle>,
hover: Option<acadrust::Handle>,
) {
use mesh_gpu::{Highlight, MeshGpu};
let mut out = Vec::new();
if selected.is_empty() && hover.is_none() {
self.gpu_mesh_highlight = out;
return;
}
for set in meshes {
let Some(m) = set.lods.iter().find(|m| !m.indices.is_empty()) else {
continue;
};
let h = m.name.parse::<u64>().ok().map(acadrust::Handle::new);
let mode = match h {
Some(h) if selected.contains(&h) => Highlight::Selected,
Some(h) if Some(h) == hover => Highlight::Hover,
_ => continue,
};
out.push(MeshGpu::new(device, m, mode));
}
self.gpu_mesh_highlight = out;
}
/// 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
@@ -1413,8 +1526,8 @@ impl Pipeline {
}
}
// ── Pass 4: solid meshes ──────────────────────────────────────────
if !self.gpu_meshes.is_empty() {
// ── Pass 4: solid meshes (batched) ────────────────────────────────
if !self.gpu_mesh_batch.is_empty() {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mesh.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
@@ -1450,51 +1563,31 @@ impl Pipeline {
// wire pass keeps the edges crisp on
// top of the shaded surface.
let want_solid_with_edges = !hidden_line && !mesh_wireframe && show_3d_edges;
// Each path now binds a chunk's buffers and draws the whole chunk in
// one call — a handful of draws total instead of one per solid. No
// per-solid LOD / frustum cull in the batched path (the batch is
// resident in full); that is reintroduced separately if needed.
if hidden_line {
pass.set_pipeline(&self.mesh_depth_pipeline);
for (i, set) in self.gpu_meshes.iter().enumerate() {
if !self.mesh_visible.get(i).copied().unwrap_or(true) {
for c in &self.gpu_mesh_batch {
if c.index_count == 0 {
continue;
}
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);
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
pass.set_index_buffer(c.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..c.index_count, 0, 0..1);
}
pass.set_pipeline(&self.mesh_wireframe_pipeline);
for (i, set) in self.gpu_meshes.iter().enumerate() {
if !self.mesh_visible.get(i).copied().unwrap_or(true) {
for c in &self.gpu_mesh_batch {
if c.wire_index_count == 0 {
continue;
}
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.wire_index_count == 0 {
continue;
}
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
pass.set_index_buffer(
mesh.wire_index_buffer.slice(..),
c.wire_index_buffer.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.draw_indexed(0..mesh.wire_index_count, 0, 0..1);
pass.draw_indexed(0..c.wire_index_count, 0, 0..1);
}
} else {
if mesh_wireframe {
@@ -1502,59 +1595,48 @@ impl Pipeline {
} else {
pass.set_pipeline(&self.mesh_pipeline);
}
for (i, set) in self.gpu_meshes.iter().enumerate() {
if !self.mesh_visible.get(i).copied().unwrap_or(true) {
continue;
}
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;
};
for c in &self.gpu_mesh_batch {
let (ibuf, icount) = if mesh_wireframe {
(&mesh.wire_index_buffer, mesh.wire_index_count)
(&c.wire_index_buffer, c.wire_index_count)
} else {
(&mesh.index_buffer, mesh.index_count)
(&c.index_buffer, c.index_count)
};
if icount == 0 {
continue;
}
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..icount, 0, 0..1);
}
// *WithEdges variants: overlay wire-edge segments on top
// of the shaded fill. The LessEqual depth test on the
// wireframe pipeline keeps the edges visible at the
// fragments that just got written by the solid pass.
// Selection / hover highlight: tinted copies of the picked
// solids, drawn last with the Always-depth highlight pipeline so
// the tint shows on top even when the solid is behind others.
if !self.gpu_mesh_highlight.is_empty() {
pass.set_pipeline(&self.mesh_highlight_pipeline);
for g in &self.gpu_mesh_highlight {
if g.index_count == 0 {
continue;
}
pass.set_vertex_buffer(0, g.vertex_buffer.slice(..));
pass.set_index_buffer(g.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..g.index_count, 0, 0..1);
}
}
// *WithEdges variants: overlay wire-edge segments on top of the
// shaded fill. The LessEqual depth test on the wireframe pipeline
// keeps the edges visible over the fragments the solid pass wrote.
if want_solid_with_edges {
pass.set_pipeline(&self.mesh_wireframe_pipeline);
for (i, set) in self.gpu_meshes.iter().enumerate() {
if !self.mesh_visible.get(i).copied().unwrap_or(true) {
for c in &self.gpu_mesh_batch {
if c.wire_index_count == 0 {
continue;
}
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.wire_index_count == 0 {
continue;
}
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
pass.set_index_buffer(
mesh.wire_index_buffer.slice(..),
c.wire_index_buffer.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.draw_indexed(0..mesh.wire_index_count, 0, 0..1);
pass.draw_indexed(0..c.wire_index_count, 0, 0..1);
}
}
}