From 132efe6574a0c58667768f14a4b4a1619b63264f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 27 May 2026 05:57:19 +0300 Subject: [PATCH] feat(render): Flat/Gouraud shading, WithEdges overlay, paper-viewport mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faz 3 — FlatShaded vs GouraudShaded - Uniforms.flat_shade (f32, 0/1) + mesh shader fragment derivative bazlı per-triangle face normal hesabı; GouraudShaded ise interpolate edilen vertex normalini kullanıyor. Faz 4 — *WithEdges varyantları - Pipeline::render.show_3d_edges parametresi: - FlatShaded / GouraudShaded → 3DFACE edge pass'i atlanıyor - *WithEdges varyantları → shaded fill'in üstüne mesh_wireframe pipeline ile triangle-edge overlay (LessEqual depth test ile) - face3d_edges pass'i show_3d_edges ile gate'lendi Faz 5 — Paper-space viewport entity round-trip - build_viewport_primitive / build_active_viewport_primitive artık render_mode parametresi almıyor; viewport entity'sinin kendi render_mode alanından okuyor. PaperViewportPane'dan render_mode field'i kaldırıldı. Model layout'taki tab.render_mode hala pick_list ile yönetiliyor. Co-Authored-By: Claude Opus 4.7 --- src/app/view.rs | 2 +- src/scene/pipeline/mod.rs | 55 +++++++++++++++++++++++++++------- src/scene/pipeline/uniforms.rs | 10 +++++++ src/scene/render.rs | 53 +++++++++++++++++++++++++------- src/scene/viewport_pane.rs | 33 +++++++------------- src/shaders/mesh.wgsl | 44 +++++++++++++++++++-------- 6 files changed, 141 insertions(+), 56 deletions(-) diff --git a/src/app/view.rs b/src/app/view.rs index 8f7c926cf..302fd3fd1 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -883,7 +883,7 @@ fn paper_canvas_view<'a>(tab: &'a super::document::DocumentTab) -> Element<'a, M let w = rect.width.clamp(1.0, canvas_w - x); let h = rect.height.clamp(1.0, canvas_h - y); - let vp_widget = shader(PaperViewportPane::new(scene, vp_handle, tab.render_mode)) + let vp_widget = shader(PaperViewportPane::new(scene, vp_handle)) .width(iced::Length::Fixed(w)) .height(iced::Length::Fixed(h)); diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 3cca07583..8d1594f7a 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1028,6 +1028,7 @@ impl Pipeline { bg_color: [f32; 4], mesh_wireframe: bool, hidden_line: bool, + show_3d_edges: bool, ) { let vp = clip_bounds; let msaa = &self.msaa_view; @@ -1156,15 +1157,17 @@ impl Pipeline { }); pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); pass.set_bind_group(0, &self.uniform_bind_group, &[]); - // Three draw modes share this pass: - // - Solid: `mesh_pipeline` + the triangle index buffer. - // - Wireframe: `mesh_wireframe_pipeline` + the pre-built - // `wire_index_buffer` (each triangle edge expanded to a - // line segment at upload). - // - HiddenLine: depth prepass with `mesh_depth_pipeline` - // (writes Z, no colour), then wire-edge draw on top. - // The wire draw's LessEqual depth test naturally hides - // edges occluded by the prepass. + // Four draw paths share this pass: + // - Solid: `mesh_pipeline` + triangle index buf. + // - Wireframe: `mesh_wireframe_pipeline` + the + // pre-built `wire_index_buffer`. + // - HiddenLine: depth prepass (`mesh_depth_pipeline`, + // writes Z, no colour) → wire overlay. + // - Solid+Edges: `mesh_pipeline` shaded fill → wire + // overlay; LessEqual depth test on the + // wire pass keeps the edges crisp on + // top of the shaded surface. + let want_solid_with_edges = !hidden_line && !mesh_wireframe && show_3d_edges; if hidden_line { pass.set_pipeline(&self.mesh_depth_pipeline); for (i, set) in self.gpu_meshes.iter().enumerate() { @@ -1242,6 +1245,36 @@ impl Pipeline { 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. + 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) { + 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_index_buffer( + mesh.wire_index_buffer.slice(..), + wgpu::IndexFormat::Uint32, + ); + pass.draw_indexed(0..mesh.wire_index_count, 0, 0..1); + } + } } } @@ -1293,7 +1326,9 @@ impl Pipeline { } // ── Pass 5b: 3DFACE edges (batched, possibly multiple chunks) ──── - if !self.gpu_face3d_edges.is_empty() { + // FlatShaded / GouraudShaded hide the 3DFACE outline (the user + // chose a clean shaded look); every other mode keeps it. + if show_3d_edges && !self.gpu_face3d_edges.is_empty() { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("face3d_edges.render_pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { diff --git a/src/scene/pipeline/uniforms.rs b/src/scene/pipeline/uniforms.rs index 3f6f1b577..fa19c67d0 100644 --- a/src/scene/pipeline/uniforms.rs +++ b/src/scene/pipeline/uniforms.rs @@ -14,6 +14,14 @@ pub struct Uniforms { /// LWDISPLAY toggle (1.0 = show lineweights, 0.0 = force 1 px). /// Read by the wire shader so the toggle does not require a retessellate. pub lwdisplay_enable: f32, + /// 1.0 → mesh fragment shader replaces the interpolated vertex + /// normal with `normalize(cross(dpdx(pos), dpdy(pos)))` so each + /// triangle gets a uniform shade (FlatShaded mode); 0.0 → keeps the + /// per-vertex normal interpolation (GouraudShaded-style). + pub flat_shade: f32, + /// Pads the struct to 112 B (next multiple of 16) so wgpu's uniform + /// alignment rules are satisfied. + pub _pad: [f32; 3], } impl Uniforms { @@ -30,6 +38,8 @@ impl Uniforms { viewport_size: [bounds.width, bounds.height], world_per_pixel, lwdisplay_enable: if lwdisplay_enable { 1.0 } else { 0.0 }, + flat_shade: 0.0, + _pad: [0.0; 3], } } } diff --git a/src/scene/render.rs b/src/scene/render.rs index d410e070e..5b6cb4e20 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -107,12 +107,10 @@ pub struct Primitive { /// in `Wireframe2D` / `Wireframe3D`; on for every shaded variant. Set /// at the same point `view_wireframe` is computed so the two stay in /// lock-step for the gating logic in `prepare()`. - #[allow(dead_code)] pub(super) mesh_fill: bool, /// Whether the active render mode wants 3D mesh / face edges /// rendered on top of fills. Most shaded modes turn this off; the /// `*WithEdges` variants and the pure wireframes leave it on. - #[allow(dead_code)] pub(super) show_3d_edges: bool, /// HiddenLine routes 3D fills through a depth-only prepass so edges /// occluded by closer geometry are culled by the LessEqual depth @@ -138,6 +136,11 @@ pub struct RenderModeFlags { pub mesh_fill: bool, pub show_3d_edges: bool, pub hidden_line: bool, + /// `true` for FlatShaded / FlatShadedWithEdges. The mesh shader + /// reads `Uniforms.flat_shade` and replaces the smooth per-vertex + /// normal with a per-triangle face normal so each triangle reads + /// as a single tone. + pub flat_shade: bool, } pub fn render_mode_flags( @@ -150,24 +153,42 @@ pub fn render_mode_flags( mesh_fill: false, show_3d_edges: true, hidden_line: false, + flat_shade: false, }, M::HiddenLine => RenderModeFlags { face3d_fill: true, mesh_fill: true, show_3d_edges: true, hidden_line: true, + flat_shade: false, }, - M::FlatShaded | M::GouraudShaded => RenderModeFlags { + M::FlatShaded => RenderModeFlags { face3d_fill: true, mesh_fill: true, show_3d_edges: false, hidden_line: false, + flat_shade: true, }, - M::FlatShadedWithEdges | M::GouraudShadedWithEdges => RenderModeFlags { + M::GouraudShaded => RenderModeFlags { + face3d_fill: true, + mesh_fill: true, + show_3d_edges: false, + hidden_line: false, + flat_shade: false, + }, + M::FlatShadedWithEdges => RenderModeFlags { face3d_fill: true, mesh_fill: true, show_3d_edges: true, hidden_line: false, + flat_shade: true, + }, + M::GouraudShadedWithEdges => RenderModeFlags { + face3d_fill: true, + mesh_fill: true, + show_3d_edges: true, + hidden_line: false, + flat_shade: false, }, } } @@ -267,6 +288,7 @@ impl shader::Primitive for Primitive { self.bg_color, mesh_wireframe, self.hidden_line, + self.show_3d_edges, ); if self.show_viewcube { pipeline.viewcube.render(encoder, target, *clip); @@ -455,6 +477,9 @@ impl Scene { self.paper_bg_color }; + let mut uniforms = Uniforms::new(&cam, bounds, self.document.header.lineweight_display); + uniforms.flat_shade = if flags.flat_shade { 1.0 } else { 0.0 }; + Primitive { wires: all_wires, face3d_wires: Arc::new(face3d_wires), @@ -462,7 +487,7 @@ impl Scene { wipeout_hatches: self.wipeout_models_arc(), images: self.images_arc(), meshes: self.meshes_arc(), - uniforms: Uniforms::new(&cam, bounds, self.document.header.lineweight_display), + uniforms, cam_rotation: cam.view_rotation_mat(), hover_region, bg_color, @@ -478,14 +503,20 @@ impl Scene { } /// Build a Primitive that renders model-space content through a specific - /// paper-space viewport's camera, applying its layer-freeze list. + /// paper-space viewport's camera, applying its layer-freeze list. The + /// render mode is read from the viewport entity itself (each paper-space + /// viewport carries its own visual style) — the model-space pick_list + /// only governs the Model layout. pub(super) fn build_viewport_primitive( &self, vp_handle: Handle, hover_region: Option, bounds: Rectangle, - render_mode: acadrust::entities::ViewportRenderMode, ) -> Primitive { + let render_mode = match self.document.get_entity(vp_handle) { + Some(EntityType::Viewport(vp)) => vp.render_mode, + _ => acadrust::entities::ViewportRenderMode::Wireframe2D, + }; let flags = render_mode_flags(render_mode); let view_wireframe = !flags.face3d_fill; let cam = match self.camera_for_viewport(vp_handle) { @@ -506,6 +537,9 @@ impl Scene { Arc::new(v) }; + let mut uniforms = Uniforms::new(&cam, bounds, self.document.header.lineweight_display); + uniforms.flat_shade = if flags.flat_shade { 1.0 } else { 0.0 }; + Primitive { wires: all_wires, face3d_wires: Arc::new(face3d_wires), @@ -513,7 +547,7 @@ impl Scene { wipeout_hatches: self.wipeout_models_arc(), images: self.images_arc(), meshes: self.meshes_arc(), - uniforms: Uniforms::new(&cam, bounds, self.document.header.lineweight_display), + uniforms, cam_rotation: cam.view_rotation_mat(), hover_region, bg_color: self.bg_color, @@ -535,10 +569,9 @@ impl Scene { vp_handle: Handle, hover_region: Option, bounds: Rectangle, - render_mode: acadrust::entities::ViewportRenderMode, ) -> PaperViewportPrimitive { PaperViewportPrimitive( - self.build_viewport_primitive(vp_handle, hover_region, bounds, render_mode), + self.build_viewport_primitive(vp_handle, hover_region, bounds), ) } diff --git a/src/scene/viewport_pane.rs b/src/scene/viewport_pane.rs index a5be3fc3e..142c65ab0 100644 --- a/src/scene/viewport_pane.rs +++ b/src/scene/viewport_pane.rs @@ -20,6 +20,9 @@ pub struct ViewportPane<'a> { pub scene: &'a Scene, pub mode: ViewportPaneMode, pub show_viewcube: bool, + /// Render mode for the **Model** layout view. Paper-space viewports + /// read their own `render_mode` field from the viewport entity, so + /// this value is ignored when `mode` is `Paper`. pub render_mode: acadrust::entities::ViewportRenderMode, } @@ -59,20 +62,11 @@ impl<'a> ViewportPane<'a> { pub struct PaperViewportPane<'a> { pub scene: &'a Scene, pub handle: Handle, - pub render_mode: acadrust::entities::ViewportRenderMode, } impl<'a> PaperViewportPane<'a> { - pub fn new( - scene: &'a Scene, - handle: Handle, - render_mode: acadrust::entities::ViewportRenderMode, - ) -> Self { - Self { - scene, - handle, - render_mode, - } + pub fn new(scene: &'a Scene, handle: Handle) -> Self { + Self { scene, handle } } } @@ -87,12 +81,7 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for PaperViewportPan bounds: Rectangle, ) -> Self::Primitive { self.scene - .build_active_viewport_primitive( - self.handle, - state.hover_region, - bounds, - self.render_mode, - ) + .build_active_viewport_primitive(self.handle, state.hover_region, bounds) } fn update( @@ -136,12 +125,10 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> self.show_viewcube, self.render_mode, ), - ViewportPaneMode::Paper { handle } => self.scene.build_viewport_primitive( - *handle, - state.hover_region, - bounds, - self.render_mode, - ), + ViewportPaneMode::Paper { handle } => { + self.scene + .build_viewport_primitive(*handle, state.hover_region, bounds) + } } } diff --git a/src/shaders/mesh.wgsl b/src/shaders/mesh.wgsl index 02cbbc6da..809ffc257 100644 --- a/src/shaders/mesh.wgsl +++ b/src/shaders/mesh.wgsl @@ -2,13 +2,23 @@ // // Vertex layout: position [f32;3], normal [f32;3], color [f32;4] (40 bytes) // -// Lighting: simple half-Lambert with a fixed directional light so solids look -// solid regardless of camera angle. Selection highlight overrides color. +// Lighting: simple half-Lambert with a fixed directional light. Two +// shading paths share this shader, picked per-frame via `u.flat_shade`: +// - 0.0 → per-vertex normals interpolated to the fragment (Gouraud). +// - 1.0 → per-triangle face normal from screen-space derivatives +// `cross(dpdx(pos), dpdy(pos))`, so each triangle reads as a single +// flat shade (FlatShaded). struct Uniforms { - view_proj: mat4x4, - viewport: vec2, - _pad: vec2, + view_proj: mat4x4, + camera_pos: vec4, + viewport_size: vec2, + world_per_pixel: f32, + lwdisplay_enable: f32, + flat_shade: f32, + _pad0: f32, + _pad1: f32, + _pad2: f32, }; @group(0) @binding(0) @@ -21,17 +31,19 @@ struct VertexIn { }; struct VertexOut { - @builtin(position) clip_pos: vec4, - @location(0) color: vec4, - @location(1) normal: vec3, + @builtin(position) clip_pos: vec4, + @location(0) color: vec4, + @location(1) normal: vec3, + @location(2) world_pos: vec3, }; @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; - out.normal = v.normal; + out.clip_pos = u.view_proj * vec4(v.position, 1.0); + out.color = v.color; + out.normal = v.normal; + out.world_pos = v.position; return out; } @@ -39,7 +51,15 @@ fn vs_main(v: VertexIn) -> VertexOut { fn fs_main(in: VertexOut) -> @location(0) vec4 { // Fixed directional light (world space, tilted toward the viewer). let light_dir = normalize(vec3(0.4, 0.8, 0.6)); - let n = normalize(in.normal); + var n: vec3; + if (u.flat_shade > 0.5) { + // Per-triangle face normal: derivatives of the interpolated + // world position are constant across the primitive, so every + // fragment in the same triangle sees the same normal. + n = normalize(cross(dpdx(in.world_pos), dpdy(in.world_pos))); + } else { + n = normalize(in.normal); + } // Half-Lambert: [0.5, 1.0] so the dark side isn't fully black. let diff = clamp(dot(n, light_dir), 0.0, 1.0) * 0.5 + 0.5; let rgb = in.color.rgb * diff;