perf: dedicated Face3D shader — N draw calls → 2

3DFACE entities previously created one WireGpu buffer per entity,
resulting in N separate draw calls per frame. For files with large
triangle meshes this was the dominant GPU CPU overhead.

New pipeline:
- face3d.wgsl: simple flat-color vertex + fragment shader (MVP only,
  no quad expansion, no pattern distance computation)
- Face3DGpu: batches ALL Face3D entities into a single TriangleList
  buffer (6 vertices per face, per-vertex color) — 1 draw call
- WireGpu::from_batch: merges N Face3D edge wire models into one
  quad-expanded buffer for edge rendering — 1 draw call
- split_face3d_wires: separates face3d from other wires at Primitive
  build time via document handle lookup (O(N), once per epoch)

Hit-testing via WireModel (CPU) is unaffected — face3d wires remain
in entity_wires_arc() and hit_test_wires() as before.

Before: N 3DFACE → N draw calls, N × 96 B/vertex WireVertex
After:  N 3DFACE → 2 draw calls (fill + edges), 28 B/vertex fill

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-28 21:53:31 +03:00
commit 298098e764
5 changed files with 385 additions and 4 deletions

View file

@ -78,6 +78,9 @@ pub struct CameraState {
#[derive(Debug)]
pub struct Primitive {
pub(super) wires: Arc<Vec<WireModel>>,
/// 3DFACE entity wires — separated so they are uploaded to the dedicated
/// face3d pipeline (fill + batched edges) instead of N individual WireGpu.
pub(super) face3d_wires: Arc<Vec<WireModel>>,
pub(super) hatches: Arc<Vec<HatchModel>>,
/// Wipeout fills — rendered in a separate pass AFTER wires.
pub(super) wipeout_hatches: Arc<Vec<HatchModel>>,
@ -125,6 +128,7 @@ impl shader::Primitive for Primitive {
pipeline.upload_images(device, queue, &self.images[..]);
pipeline.upload_meshes(device, &self.meshes[..]);
pipeline.upload_wires(device, &self.wires[..]);
pipeline.upload_face3d(device, &self.face3d_wires[..]);
pipeline.cached_epoch = self.geometry_epoch;
}
if self.show_viewcube {
@ -280,10 +284,11 @@ impl Scene {
self.selection.borrow_mut().vp_size = (bounds.width, bounds.height);
let entity_arc = self.entity_wires_arc();
let (face3d_wires, other_wires) = split_face3d_wires(&entity_arc, &self.document);
let all_wires = if self.interim_wire.is_none() && self.preview_wires.is_empty() {
entity_arc
Arc::new(other_wires)
} else {
let mut v = (*entity_arc).clone();
let mut v = other_wires;
if let Some(iw) = &self.interim_wire {
v.push(iw.clone());
}
@ -299,6 +304,7 @@ impl Scene {
Primitive {
wires: all_wires,
face3d_wires: Arc::new(face3d_wires),
hatches: self.hatch_models_arc(),
wipeout_hatches: self.wipeout_models_arc(),
images: self.images_arc(),
@ -327,10 +333,11 @@ impl Scene {
};
let base_arc = self.model_wires_for_viewport_arc(vp_handle);
let (face3d_wires, other_wires) = split_face3d_wires(&base_arc, &self.document);
let all_wires = if self.interim_wire.is_none() && self.preview_wires.is_empty() {
base_arc
Arc::new(other_wires)
} else {
let mut v = (*base_arc).clone();
let mut v = other_wires;
if let Some(iw) = &self.interim_wire {
v.push(iw.clone());
}
@ -340,6 +347,7 @@ impl Scene {
Primitive {
wires: all_wires,
face3d_wires: Arc::new(face3d_wires),
hatches: self.hatch_models_arc(),
wipeout_hatches: self.wipeout_models_arc(),
images: self.images_arc(),
@ -439,3 +447,28 @@ pub(super) fn resolve_pattern(
}
(pat_len, pat)
}
/// Partition a wire list into (face3d_wires, other_wires).
///
/// Uses a document handle lookup so no changes to WireModel are needed.
/// O(N) per geometry epoch — acceptable since it runs once per epoch.
fn split_face3d_wires(
wires: &[WireModel],
document: &acadrust::CadDocument,
) -> (Vec<WireModel>, Vec<WireModel>) {
let mut face3d = Vec::new();
let mut others = Vec::new();
for w in wires {
let is_face3d = w.name.parse::<u64>()
.ok()
.and_then(|v| document.get_entity(Handle::new(v)))
.map(|e| matches!(e, EntityType::Face3D(_)))
.unwrap_or(false);
if is_face3d {
face3d.push(w.clone());
} else {
others.push(w.clone());
}
}
(face3d, others)
}