From 298098e764c47662059bc6250610225d529df2a0 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 28 Apr 2026 21:53:31 +0300 Subject: [PATCH] =?UTF-8?q?perf:=20dedicated=20Face3D=20shader=20=E2=80=94?= =?UTF-8?q?=20N=20draw=20calls=20=E2=86=92=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/scene/pipeline/face3d_gpu.rs | 93 ++++++++++++++++++++ src/scene/pipeline/mod.rs | 142 +++++++++++++++++++++++++++++++ src/scene/pipeline/wire_gpu.rs | 76 +++++++++++++++++ src/scene/render.rs | 41 ++++++++- src/shaders/face3d.wgsl | 37 ++++++++ 5 files changed, 385 insertions(+), 4 deletions(-) create mode 100644 src/scene/pipeline/face3d_gpu.rs create mode 100644 src/shaders/face3d.wgsl diff --git a/src/scene/pipeline/face3d_gpu.rs b/src/scene/pipeline/face3d_gpu.rs new file mode 100644 index 000000000..c39bd0cda --- /dev/null +++ b/src/scene/pipeline/face3d_gpu.rs @@ -0,0 +1,93 @@ +// Face3D GPU buffer — batches all DXF 3DFACE entities into a single +// TriangleList buffer for efficient rendering. +// +// Each Face3D quad (4 corners) produces 2 triangles → 6 vertices. +// All entities are merged into one wgpu::Buffer → 1 draw call total. +// +// Vertex layout (28 bytes): +// position [f32; 3] offset 0 12 B +// color [f32; 4] offset 12 16 B +// ------ +// 28 B / vertex + +use crate::scene::wire_model::WireModel; +use iced::wgpu; +use iced::wgpu::util::DeviceExt; + +// ── Vertex layout ────────────────────────────────────────────────────────── + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct Face3DVertex { + pub position: [f32; 3], + pub color: [f32; 4], +} + +impl Face3DVertex { + pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: 12, + shader_location: 1, + format: wgpu::VertexFormat::Float32x4, + }, + ], + } + } +} + +// ── GPU handle ───────────────────────────────────────────────────────────── + +pub struct Face3DGpu { + pub vertex_buffer: wgpu::Buffer, + pub vertex_count: u32, +} + +impl Face3DGpu { + /// Build a batched GPU buffer from Face3D wire models. + /// + /// Each WireModel's `key_vertices` holds the 4 corners in local space + /// (world_offset already applied by tessellate.rs). Two triangles are + /// emitted per face: (p0,p1,p2) and (p0,p2,p3). + pub fn from_wires(device: &wgpu::Device, wires: &[WireModel]) -> Self { + let mut vertices: Vec = Vec::with_capacity(wires.len() * 6); + + for wire in wires { + // key_vertices has exactly 4 entries for Face3D (p0..p3). + if wire.key_vertices.len() < 4 { + continue; + } + let color = wire.color; + let p = &wire.key_vertices; + let v = |i: usize| Face3DVertex { position: p[i], color }; + + // Triangle 1: p0, p1, p2 + vertices.push(v(0)); + vertices.push(v(1)); + vertices.push(v(2)); + // Triangle 2: p0, p2, p3 + vertices.push(v(0)); + vertices.push(v(2)); + vertices.push(v(3)); + } + + let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("face3d.vbuf"), + contents: bytemuck::cast_slice(&vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + + Self { + vertex_buffer, + vertex_count: vertices.len() as u32, + } + } +} diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 473b3dc87..ed49d4bec 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,3 +1,4 @@ +pub mod face3d_gpu; pub mod hatch_gpu; pub mod image_gpu; pub mod mesh_gpu; @@ -8,6 +9,7 @@ pub mod wire_gpu; use iced::wgpu; use iced::{Rectangle, Size}; +pub use face3d_gpu::Face3DGpu; pub use hatch_gpu::HatchGpu; pub use image_gpu::ImageGpu; pub use mesh_gpu::MeshGpu; @@ -31,6 +33,7 @@ pub struct Pipeline { hatch_pipeline: wgpu::RenderPipeline, image_pipeline: wgpu::RenderPipeline, mesh_pipeline: wgpu::RenderPipeline, + face3d_pipeline: wgpu::RenderPipeline, uniform_buffer: wgpu::Buffer, uniform_bind_group: wgpu::BindGroup, hatch_bgl1: wgpu::BindGroupLayout, @@ -56,6 +59,9 @@ pub struct Pipeline { gpu_wipeouts: Vec, gpu_images: Vec, gpu_meshes: Vec, + /// Batched 3DFACE fill (all faces in one buffer) and edges (merged wire). + gpu_face3d_fill: Option, + gpu_face3d_edges: Option, pub viewcube: ViewCubePipeline, /// Last geometry epoch for which GPU buffers were uploaded. /// Initialized to u64::MAX so the first frame always uploads. @@ -319,6 +325,60 @@ impl Pipeline { cache: None, }); + // ── Face3D pipeline ──────────────────────────────────────────────── + let face3d_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("face3d.shader"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( + "../../shaders/face3d.wgsl" + ))), + }); + + let face3d_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("face3d.pipeline_layout"), + bind_group_layouts: &[&frame_bgl], + push_constant_ranges: &[], + }); + + let face3d_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("face3d.pipeline"), + layout: Some(&face3d_layout), + vertex: wgpu::VertexState { + module: &face3d_shader, + entry_point: Some("vs_main"), + buffers: &[face3d_gpu::Face3DVertex::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: true, + depth_compare: wgpu::CompareFunction::LessEqual, + 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: &face3d_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, + }); + // ── Image pipeline ───────────────────────────────────────────────── let image_bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("image.bgl1"), @@ -509,6 +569,7 @@ impl Pipeline { hatch_pipeline, image_pipeline, mesh_pipeline, + face3d_pipeline, uniform_buffer, uniform_bind_group, hatch_bgl1, @@ -528,6 +589,8 @@ impl Pipeline { gpu_wipeouts: vec![], gpu_images: vec![], gpu_meshes: vec![], + gpu_face3d_fill: None, + gpu_face3d_edges: None, viewcube, cached_epoch: u64::MAX, } @@ -544,6 +607,19 @@ impl Pipeline { .collect(); } + /// Upload all 3DFACE entities as two batched GPU objects: + /// - `gpu_face3d_fill`: filled triangles (1 buffer, 1 draw call) + /// - `gpu_face3d_edges`: merged edge wires (1 buffer, 1 draw call) + pub fn upload_face3d(&mut self, device: &wgpu::Device, face3d_wires: &[WireModel]) { + if face3d_wires.is_empty() { + self.gpu_face3d_fill = None; + self.gpu_face3d_edges = None; + return; + } + self.gpu_face3d_fill = Some(Face3DGpu::from_wires(device, face3d_wires)); + self.gpu_face3d_edges = WireGpu::from_batch(device, face3d_wires); + } + pub fn upload_meshes(&mut self, device: &wgpu::Device, meshes: &[MeshModel]) { self.gpu_meshes = meshes .iter() @@ -704,6 +780,72 @@ impl Pipeline { } } + // ── Pass 5a: 3DFACE fills ───────────────────────────────────────── + if let Some(ref fill) = self.gpu_face3d_fill { + if fill.vertex_count > 0 { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("face3d.render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: msaa, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); + pass.set_pipeline(&self.face3d_pipeline); + pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_vertex_buffer(0, fill.vertex_buffer.slice(..)); + pass.draw(0..fill.vertex_count, 0..1); + } + } + + // ── Pass 5b: 3DFACE edges (batched) ────────────────────────────── + if let Some(ref edges) = self.gpu_face3d_edges { + if edges.vertex_count >= 6 { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("face3d_edges.render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: msaa, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); + pass.set_pipeline(&self.wire_pipeline); + pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_vertex_buffer(0, edges.vertex_buffer.slice(..)); + pass.draw(0..edges.vertex_count, 0..1); + } + } + // ── Pass 5: wires ───────────────────────────────────────────────── { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 7801d2564..1c117b7db 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -125,6 +125,82 @@ impl WireGpu { Self::build(device, wire, [r, g, b, a * alpha]) } + /// Merge multiple WireModels into a single GPU buffer (1 draw call for all). + /// Each wire keeps its own color and pattern — they're stored per-vertex. + /// Returns None if the combined vertex list is empty. + pub fn from_batch(device: &wgpu::Device, wires: &[WireModel]) -> Option { + let total_segs: usize = wires.iter().map(|w| w.points.len().saturating_sub(1)).sum(); + if total_segs == 0 { + return None; + } + let mut vertices: Vec = Vec::with_capacity(total_segs * 6); + + for wire in wires { + let color = wire.color; + let pat0 = [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]]; + let pat1 = [wire.pattern[4], wire.pattern[5], wire.pattern[6], wire.pattern[7]]; + let half_width = wire.line_weight_px * 0.5; + let n = wire.points.len(); + + // Cumulative arc-length per point. + let mut dists = vec![0.0_f32; n]; + for i in 1..n { + let p = wire.points[i - 1]; + let q = wire.points[i]; + if !p[0].is_finite() || !q[0].is_finite() { + dists[i] = dists[i - 1]; + } else { + let dx = q[0] - p[0]; + let dy = q[1] - p[1]; + let dz = q[2] - p[2]; + dists[i] = dists[i - 1] + (dx * dx + dy * dy + dz * dz).sqrt(); + } + } + + for i in 0..n.saturating_sub(1) { + let a = wire.points[i]; + let b = wire.points[i + 1]; + if !a[0].is_finite() || !a[1].is_finite() || !a[2].is_finite() + || !b[0].is_finite() || !b[1].is_finite() || !b[2].is_finite() + { + continue; + } + let dist_a = dists[i]; + let dist_b = dists[i + 1]; + let make = |which_end: f32, side: f32| -> WireVertex { + let dist = if which_end < 0.5 { dist_a } else { dist_b }; + WireVertex { + pos_a: a, pos_b: b, which_end, side, color, + distance: dist, half_width, + pattern_length: wire.pattern_length, + _pad: 0.0, pat0, pat1, + } + }; + vertices.push(make(0.0, -1.0)); + vertices.push(make(1.0, -1.0)); + vertices.push(make(1.0, 1.0)); + vertices.push(make(0.0, -1.0)); + vertices.push(make(1.0, 1.0)); + vertices.push(make(0.0, 1.0)); + } + } + + if vertices.is_empty() { + return None; + } + + let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("wire.batch.vbuf"), + contents: bytemuck::cast_slice(&vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + + Some(Self { + vertex_buffer, + vertex_count: vertices.len() as u32, + }) + } + fn build(device: &wgpu::Device, wire: &WireModel, color: [f32; 4]) -> Self { let pat0 = [ wire.pattern[0], diff --git a/src/scene/render.rs b/src/scene/render.rs index 16ba8847e..8e9408ef2 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -78,6 +78,9 @@ pub struct CameraState { #[derive(Debug)] pub struct Primitive { pub(super) wires: Arc>, + /// 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>, pub(super) hatches: Arc>, /// Wipeout fills — rendered in a separate pass AFTER wires. pub(super) wipeout_hatches: Arc>, @@ -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, Vec) { + let mut face3d = Vec::new(); + let mut others = Vec::new(); + for w in wires { + let is_face3d = w.name.parse::() + .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) +} diff --git a/src/shaders/face3d.wgsl b/src/shaders/face3d.wgsl new file mode 100644 index 000000000..074d17b0e --- /dev/null +++ b/src/shaders/face3d.wgsl @@ -0,0 +1,37 @@ +// Face3D shader — flat-shaded triangle fill for DXF 3DFACE entities. +// +// Vertex layout (28 bytes): +// position [f32; 3] offset 0 12 B +// color [f32; 4] offset 12 16 B + +struct Uniforms { + view_proj: mat4x4, + viewport: vec2, + _pad: vec2, +}; + +@group(0) @binding(0) +var u: Uniforms; + +struct VertexIn { + @location(0) position: vec3, + @location(1) color: vec4, +}; + +struct VertexOut { + @builtin(position) clip_pos: vec4, + @location(0) color: vec4, +}; + +@vertex +fn vs_main(v: VertexIn) -> VertexOut { + var out: VertexOut; + out.clip_pos = u.view_proj * vec4(v.position, 1.0); + out.color = v.color; + return out; +} + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 { + return in.color; +}