feat(render): Flat/Gouraud shading, WithEdges overlay, paper-viewport mode

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 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-27 05:57:19 +03:00
1 parent 56834dfba6
commit 132efe6574
6 files changed
+141 -56

No files matched your search

+1 -1
View File
@@ -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));
+45 -10
View File
@@ -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 {
+10
View File
@@ -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],
}
}
}
+43 -10
View File
@@ -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<usize>,
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<usize>,
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),
)
}
+10 -23
View File
@@ -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<Msg> 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<Msg> 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)
}
}
}
+32 -12
View File
@@ -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<f32>,
viewport: vec2<f32>,
_pad: vec2<f32>,
view_proj: mat4x4<f32>,
camera_pos: vec4<f32>,
viewport_size: vec2<f32>,
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<f32>,
@location(0) color: vec4<f32>,
@location(1) normal: vec3<f32>,
@builtin(position) clip_pos: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) normal: vec3<f32>,
@location(2) world_pos: vec3<f32>,
};
@vertex
fn vs_main(v: VertexIn) -> VertexOut {
var out: VertexOut;
out.clip_pos = u.view_proj * vec4<f32>(v.position, 1.0);
out.color = v.color;
out.normal = v.normal;
out.clip_pos = u.view_proj * vec4<f32>(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<f32> {
// Fixed directional light (world space, tilted toward the viewer).
let light_dir = normalize(vec3<f32>(0.4, 0.8, 0.6));
let n = normalize(in.normal);
var n: vec3<f32>;
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;