From d6dfd0d46d73481a47bd470813c2300cfc424690 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 4 Oct 2026 20:44:08 +0300 Subject: [PATCH] fix(render): miter the joints of wide polyline bands Each band segment was a rectangle ending square at its vertex, so the outer corner of every joint showed a notch. Every segment end now carries the tangent of half its turn, and the shader slides that end's corners onto the joint's bisector so neighbours meet in a miter. Turns sharper than the limit keep butt ends. Constant-width runs inside a polyline split around its arcs no longer count as tapered, which had kept them unmitred. Fixes #1565 Co-Authored-By: Claude Opus 5.5 --- src/scene/convert/tessellate.rs | 15 +++- src/scene/pipeline/wire_gpu.rs | 127 +++++++++++++++++++++++++--- src/shaders/block_wire.wgsl | 10 ++- src/shaders/block_wire_storage.wgsl | 12 ++- src/shaders/wire.wgsl | 10 ++- src/shaders/wire_indexed.wgsl | 10 ++- 6 files changed, 161 insertions(+), 23 deletions(-) diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 79459ef6c..917e71a3d 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -495,10 +495,17 @@ fn split_mixed_polyline( .iter() .copied() .fold(0.0f32, f32::max); - let has_line_taper = straight_widths - .first() - .map_or(false, |&w0| straight_widths.iter().any(|&w| (w - w0).abs() > 1e-6)); - let taper_widths = if has_line_taper || straight_widths.iter().any(|&w| w > 1e-6) { + // NaN breaks carry a 0 width; only real points decide whether the + // band tapers, so a constant-width run keeps its mitered joints. + let mut real_widths = line_pts + .iter() + .zip(&straight_widths) + .filter(|(p, _)| p[0].is_finite()) + .map(|(_, &w)| w); + let has_line_taper = real_widths + .next() + .map_or(false, |w0| real_widths.any(|w| (w - w0).abs() > 1e-6)); + let taper_widths = if has_line_taper { straight_widths } else { Vec::new() diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index aac7f4a3b..ee360adc4 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -71,8 +71,9 @@ pub struct WireInstance { /// Endpoint width / the per-wire maximum width, normalized by the vertex /// fetch unit. `[0, 0]` means use the constant width. Ratios retain the /// full f32 world-width scale in `WireConst` while making every instance - /// exactly one 64-byte cache line. - pub taper_ratio: [u16; 2], + /// exactly one 64-byte cache line. A constant-width band carries its + /// joint miters (`band_miters`) here instead. + pub taper_ratio: [i16; 2], } impl WireInstance { @@ -86,7 +87,7 @@ impl WireInstance { wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_a) as u64, shader_location: 4, format: wgpu::VertexFormat::Float32 }, wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_b) as u64, shader_location: 5, format: wgpu::VertexFormat::Float32 }, wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, wire_id) as u64, shader_location: 6, format: wgpu::VertexFormat::Uint32 }, - wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, taper_ratio) as u64, shader_location: 7, format: wgpu::VertexFormat::Unorm16x2 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, taper_ratio) as u64, shader_location: 7, format: wgpu::VertexFormat::Snorm16x2 }, ]; wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, @@ -288,7 +289,7 @@ pub struct BlockWireVertex { pub pos_b: [f32; 3], pub pos_b_low: [f32; 3], pub distances: [f32; 2], - pub taper_ratio: [u16; 2], + pub taper_ratio: [i16; 2], } impl BlockWireVertex { @@ -299,7 +300,7 @@ impl BlockWireVertex { wgpu::VertexAttribute { offset: std::mem::offset_of!(BlockWireVertex, pos_a_low) as u64, shader_location: 2, format: wgpu::VertexFormat::Float32x3 }, wgpu::VertexAttribute { offset: std::mem::offset_of!(BlockWireVertex, pos_b_low) as u64, shader_location: 3, format: wgpu::VertexFormat::Float32x3 }, wgpu::VertexAttribute { offset: std::mem::offset_of!(BlockWireVertex, distances) as u64, shader_location: 4, format: wgpu::VertexFormat::Float32x2 }, - wgpu::VertexAttribute { offset: std::mem::offset_of!(BlockWireVertex, taper_ratio) as u64, shader_location: 5, format: wgpu::VertexFormat::Unorm16x2 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(BlockWireVertex, taper_ratio) as u64, shader_location: 5, format: wgpu::VertexFormat::Snorm16x2 }, ]; wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, @@ -577,6 +578,96 @@ fn marker_metadata(wire: &WireModel) -> ([f32; 4], [f32; 4], [f32; 4]) { ) } +/// Largest |tan(turn/2)| a band joint is mitered at; sharper turns stay butt +/// ends instead of growing a long spike. Must match `MITER_LIMIT` in the wire +/// shaders, which decode the normalized value back. +pub(crate) const MITER_LIMIT: f64 = 8.0; + +/// Per-segment miter factors `[k_a, k_b]` of a constant-width world band, +/// normalized by `MITER_LIMIT`. The shader slides each quad corner by +/// `k·side·half_width` along the segment, so neighbouring segments meet on +/// their joint's bisector instead of leaving a notch at the outer corner. +/// Empty for every other wire. +fn band_miters(wire: &WireModel, normal: [f32; 4]) -> Vec<[f32; 2]> { + let n = wire.points.len(); + if wire.world_width <= 0.0 || !wire.taper_widths.is_empty() || n < 3 { + return Vec::new(); + } + let at = |i: usize| -> Option { + let h = wire.points[i]; + let l = wire.points_low.get(i).copied().unwrap_or([0.0; 3]); + finite3(h).then(|| { + glam::DVec3::new( + h[0] as f64 + l[0] as f64, + h[1] as f64 + l[1] as f64, + h[2] as f64 + l[2] as f64, + ) + }) + }; + let same = |a: glam::DVec3, b: glam::DVec3| a.distance(b) <= 1e-9 * (1.0 + a.length()); + let first = (0..n).find_map(|i| at(i).map(|p| (i, p))); + let last = (0..n).rev().find_map(|i| at(i).map(|p| (i, p))); + let closed = matches!((first, last), (Some((i, a)), Some((j, b))) if i < j && same(a, b)); + // Direction leaving joint `from` (or arriving at it when `step` walks + // back), skipping duplicate points and NaN breaks whose next run starts + // at the same joint; a closed wire wraps around once. + let neighbour = |from: usize, step: isize| -> Option { + let joint = at(from)?; + let mut i = from as isize; + let mut wrapped = false; + let mut broken = false; + loop { + i += step; + if i < 0 || i >= n as isize { + if !closed || wrapped { + return None; + } + wrapped = true; + i = if step > 0 { first?.0 as isize } else { last?.0 as isize }; + continue; + } + let Some(q) = at(i as usize) else { + broken = true; + continue; + }; + if same(q, joint) { + broken = false; + continue; + } + if broken { + return None; + } + let d = if step > 0 { q - joint } else { joint - q }; + return Some(d.normalize()); + } + }; + let axis = glam::DVec3::new(normal[0] as f64, normal[1] as f64, normal[2] as f64); + let axis = if axis.length() > 1e-4 { axis.normalize() } else { glam::DVec3::Z }; + // tan of half the signed turn from `d1` to `d2` about the band normal. + let half_turn = |d1: glam::DVec3, d2: glam::DVec3| -> f32 { + let cos = d1.dot(d2); + if cos <= -1.0 + 1e-9 { + return 0.0; + } + let t = axis.dot(d1.cross(d2)) / (1.0 + cos); + if t.abs() > MITER_LIMIT { 0.0 } else { (t / MITER_LIMIT) as f32 } + }; + (0..n - 1) + .map(|i| { + let (Some(a), Some(b)) = (at(i), at(i + 1)) else { + return [0.0; 2]; + }; + if same(a, b) { + return [0.0; 2]; + } + let d = (b - a).normalize(); + let k_a = neighbour(i, -1).map_or(0.0, |prev| half_turn(prev, d)); + let k_b = neighbour(i + 1, 1).map_or(0.0, |next| -half_turn(d, next)); + [k_a, k_b] + }) + .collect() +} + /// Emit packed per-segment instances (each carries the wire's constants). pub(crate) fn emit_wire_packed( wire: &WireModel, @@ -599,6 +690,7 @@ pub(crate) fn emit_wire_packed( (Vec::new(), 0.0, 0.0) }; let (marker_origin_high, marker_origin_low, marker_normal_scale) = marker_metadata(wire); + let miters = band_miters(wire, marker_normal_scale); let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; let is_tapered = !wire.taper_widths.is_empty(); let tw = |i: usize| -> f32 { @@ -638,8 +730,17 @@ pub(crate) fn emit_wire_packed( world_half_width: wire.world_width * 0.5, world_hw_a: tw(i), world_hw_b: tw(i + 1), - marker_origin_high, - marker_origin_low, + // The origin `w` slots are free: they carry the band miters. + marker_origin_high: { + let mut m = marker_origin_high; + m[3] = miters.get(i).map_or(0.0, |k| k[0]); + m + }, + marker_origin_low: { + let mut m = marker_origin_low; + m[3] = miters.get(i).map_or(0.0, |k| k[1]); + m + }, marker_normal_scale, }); } @@ -684,14 +785,17 @@ pub(crate) fn emit_wire_native( return (Vec::new(), cst); } let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; - let taper_ratio = |i: usize| -> u16 { + let snorm = |v: f32| -> i16 { (v.clamp(-1.0, 1.0) * i16::MAX as f32).round() as i16 }; + let taper_ratio = |i: usize| -> i16 { if !is_tapered || wire.world_width <= 0.0 { 0 } else { let width = wire.taper_widths.get(i).copied().unwrap_or(0.0); - ((width / wire.world_width).clamp(0.0, 1.0) * u16::MAX as f32).round() as u16 + snorm(width / wire.world_width) } }; + // A constant-width band reuses the slot for its joint miters. + let miters = band_miters(wire, marker_normal_scale); let mut instances: Vec = Vec::with_capacity(seg_count); for i in 0..seg_count { let a = wire.points[i]; @@ -712,7 +816,10 @@ pub(crate) fn emit_wire_native( distance_a: dist_a, distance_b: dist_b, wire_id, - taper_ratio: [taper_ratio(i), taper_ratio(i + 1)], + taper_ratio: match miters.get(i) { + Some(k) => [snorm(k[0]), snorm(k[1])], + None => [taper_ratio(i), taper_ratio(i + 1)], + }, }); } (instances, cst) diff --git a/src/shaders/block_wire.wgsl b/src/shaders/block_wire.wgsl index 31683b58e..8399a2c2f 100644 --- a/src/shaders/block_wire.wgsl +++ b/src/shaders/block_wire.wgsl @@ -46,6 +46,8 @@ struct VertexIn { const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; +// Must match `MITER_LIMIT` in wire_gpu.rs. +const MITER_LIMIT: f32 = 8.0; struct VertexOut { @builtin(position) clip_pos: vec4, @@ -161,13 +163,17 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut } perp_world = normalize(perp_world); + // A constant-width band slides each corner along the segment onto + // its joint's bisector (miter), so neighbours meet without a notch. + let miter = select(in.taper_ratio, vec2(0.0), is_tapered) * MITER_LIMIT; + let along = mix(miter.x, miter.y, which_end) * eff_hw * side; let pos_rel = mix(rel_a, rel_b, which_end); - let world_pos = pos_rel + perp_world * (eff_hw * side); + let world_pos = pos_rel + perp_world * (eff_hw * side) + world_dir * along; var clip_pos = u.view_rot * vec4(world_pos, 1.0); clip_pos = apply_draw_order(clip_pos, in.depth.x); final_clip = clip_pos; - out_dist = mix(in.distances.x, in.distances.y, which_end); + out_dist = mix(in.distances.x, in.distances.y, which_end) + along * (in.distances.y - in.distances.x) / max(world_len, 1e-6); out_cap = vec2(which_end * world_len, eff_hw * side); out_cap_ends = vec3(world_len, world_hw_a, world_hw_b); } else { diff --git a/src/shaders/block_wire_storage.wgsl b/src/shaders/block_wire_storage.wgsl index 33ee2d636..ebb5a7ea6 100644 --- a/src/shaders/block_wire_storage.wgsl +++ b/src/shaders/block_wire_storage.wgsl @@ -70,6 +70,8 @@ struct VertexIn { const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; +// Must match `MITER_LIMIT` in wire_gpu.rs. +const MITER_LIMIT: f32 = 8.0; struct VertexOut { @builtin(position) clip_pos: vec4, @@ -144,7 +146,7 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut let seg_pos_b = vec3(seg.pos_b_x, seg.pos_b_y, seg.pos_b_z); let seg_pos_b_low = vec3(seg.pos_b_low_x, seg.pos_b_low_y, seg.pos_b_low_z); let seg_distances = vec2(seg.distance_a, seg.distance_b); - let seg_taper_ratio = unpack2x16unorm(seg.taper_ratio); + let seg_taper_ratio = unpack2x16snorm(seg.taper_ratio); let which_end_arr = array(0.0, 1.0, 1.0, 0.0, 1.0, 0.0); let side_arr = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0); let which_end = which_end_arr[corner]; @@ -192,13 +194,17 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut } perp_world = normalize(perp_world); + // A constant-width band slides each corner along the segment onto + // its joint's bisector (miter), so neighbours meet without a notch. + let miter = select(seg_taper_ratio, vec2(0.0), is_tapered) * MITER_LIMIT; + let along = mix(miter.x, miter.y, which_end) * eff_hw * side; let pos_rel = mix(rel_a, rel_b, which_end); - let world_pos = pos_rel + perp_world * (eff_hw * side); + let world_pos = pos_rel + perp_world * (eff_hw * side) + world_dir * along; var clip_pos = u.view_rot * vec4(world_pos, 1.0); clip_pos = apply_draw_order(clip_pos, in.depth.x); final_clip = clip_pos; - out_dist = mix(seg_distances.x, seg_distances.y, which_end); + out_dist = mix(seg_distances.x, seg_distances.y, which_end) + along * (seg_distances.y - seg_distances.x) / max(world_len, 1e-6); out_cap = vec2(which_end * world_len, eff_hw * side); out_cap_ends = vec3(world_len, world_hw_a, world_hw_b); } else { diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index 69eefa06b..4d312896f 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -69,6 +69,8 @@ struct InstanceIn { const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; +// Must match `MITER_LIMIT` in wire_gpu.rs. +const MITER_LIMIT: f32 = 8.0; struct VertexOut { @builtin(position) clip_pos: vec4, @@ -191,13 +193,17 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: } perp_world = normalize(perp_world); + // A constant-width band slides each corner along the segment onto + // its joint's bisector (miter), so neighbours meet without a notch. + let miter = vec2(in.marker_origin_high.w, in.marker_origin_low.w) * MITER_LIMIT; + let along = mix(miter.x, miter.y, which_end) * eff_hw * side; let pos_rel = mix(rel_a, rel_b, which_end); - let world_pos = pos_rel + perp_world * (eff_hw * side); + let world_pos = pos_rel + perp_world * (eff_hw * side) + world_dir * along; var clip_pos = u.view_rot * vec4(world_pos, 1.0); clip_pos = apply_draw_order(clip_pos, in.misc.x); final_clip = clip_pos; - out_dist = mix(in.dists.x, in.dists.y, which_end); + out_dist = mix(in.dists.x, in.dists.y, which_end) + along * (in.dists.y - in.dists.x) / max(world_len, 1e-6); out_cap = vec2(which_end * world_len, eff_hw * side); out_cap_ends = vec3(world_len, world_hw_a, world_hw_b); } else { diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index 9344b199a..680e0bcd4 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -55,6 +55,8 @@ struct InstanceIn { const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; +// Must match `MITER_LIMIT` in wire_gpu.rs. +const MITER_LIMIT: f32 = 8.0; struct VertexOut { @builtin(position) clip_pos: vec4, @@ -168,13 +170,17 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon } perp_world = normalize(perp_world); + // A constant-width band slides each corner along the segment onto + // its joint's bisector (miter), so neighbours meet without a notch. + let miter = select(in.taper_ratio, vec2(0.0), is_tapered) * MITER_LIMIT; + let along = mix(miter.x, miter.y, which_end) * eff_hw * side; let pos_rel = mix(rel_a, rel_b, which_end); - let world_pos = pos_rel + perp_world * (eff_hw * side); + let world_pos = pos_rel + perp_world * (eff_hw * side) + world_dir * along; var clip_pos = u.view_rot * vec4(world_pos, 1.0); clip_pos = apply_draw_order(clip_pos, c.draw_depth); final_clip = clip_pos; - out_dist = mix(in.distance_a, in.distance_b, which_end); + out_dist = mix(in.distance_a, in.distance_b, which_end) + along * (in.distance_b - in.distance_a) / max(world_len, 1e-6); out_cap = vec2(which_end * world_len, eff_hw * side); out_cap_ends = vec3(world_len, world_hw_a, world_hw_b); } else {