From 6a74f12a9212194241c953864e6b7ae5d12b4569 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 23 Jun 2026 17:44:09 +0300 Subject: [PATCH] feat(render): double-single vertex positions for the wire pipeline Vertices now carry the low residual alongside the high half all the way to the GPU: tessellation splits the offset-relative f64 coordinate into (high, low) f32 (TruckTessResult::Lines/Point, the Text path, Lines / SegmentedLines, and the block-cache instance transform), WireModel and the block batch carry a paired points_low buffer, WireInstance gains pos_a_low/pos_b_low (locations 10/11), and wire.wgsl reconstructs rel = (pos_high - eye_high) + (pos_low - eye_low). This is the piece the camera-only RTE was missing: at UTM-scale coordinates the single-f32 vertex was quantized to ~0.5 m, so it snapped each time the eye crossed an f32 boundary on pan. With the low half the position is exact and the cross-drawing-paste jitter is gone. Co-Authored-By: Claude Opus 4.7 --- src/scene/cache/block_cache.rs | 53 ++++++++---- src/scene/convert/tessellate.rs | 144 +++++++++++++++++++++----------- src/scene/convert/truck_tess.rs | 55 ++++++++---- src/scene/model/solid_model.rs | 13 ++- src/scene/pipeline/wire_gpu.rs | 105 +++++++++-------------- src/shaders/wire.wgsl | 18 ++-- 6 files changed, 237 insertions(+), 151 deletions(-) diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index a255e5f39..45d9a01d6 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -35,6 +35,9 @@ const MIN_PIXEL_SIZE: f32 = 2.0; #[derive(Clone, Debug)] pub struct LocalWire { pub points: Vec<[f32; 3]>, + /// Low-bit residual paired with `points` so block-instance wires keep + /// sub-f32 precision once the renderer translates them to world space. + pub points_low: Vec<[f32; 3]>, pub key_vertices: Vec<[f32; 3]>, pub snap_pts: Vec<(glam::Vec3, SnapHint)>, pub tangent_geoms: Vec, @@ -521,6 +524,7 @@ fn tessellate_sub_local( Some(LocalWire { points: wire.points, + points_low: wire.points_low, key_vertices: wire.key_vertices, snap_pts: wire.snap_pts, tangent_geoms: wire.tangent_geoms, @@ -947,7 +951,11 @@ fn emit_text_baseline( && !entry.points.last().map(|p| p[0].is_nan()).unwrap_or(false); if needs_sep { entry.points.push([f32::NAN; 3]); + entry.points_low.push([0.0; 3]); } + // Greek-substitute lines come from the LOD hatch path; their + // positions already sit at the same magnitude as other batched + // points, so a zero low component matches the f32 cast `xf` did. for q in [p0, p1] { if q[0] < entry.min_x { entry.min_x = q[0]; @@ -962,6 +970,7 @@ fn emit_text_baseline( entry.max_y = q[1]; } entry.points.push(q); + entry.points_low.push([0.0; 3]); } } } @@ -1031,6 +1040,7 @@ struct BatchEntry { aci: u8, plinegen: bool, points: Vec<[f32; 3]>, + points_low: Vec<[f32; 3]>, snap_pts: Vec<(glam::Vec3, SnapHint)>, key_vertices: Vec<[f32; 3]>, tangent_geoms: Vec, @@ -1106,7 +1116,7 @@ impl Batches { WireModel { name: name.to_string(), points: b.points, - points_low: Vec::new(), + points_low: b.points_low, color, selected, pattern_length: b.pattern_length, @@ -1394,31 +1404,46 @@ fn emit_wire( if !lw.points.is_empty() { if needs_sep { entry.points.push([f32::NAN; 3]); + entry.points_low.push([0.0; 3]); } - for p in &lw.points { + // Iterate paired with the matching low residual so the GPU keeps + // sub-f32 precision once the INSERT transform lands the wire in + // world space at UTM-scale coordinates. + for (idx, p) in lw.points.iter().enumerate() { if p[0].is_nan() { entry.points.push([f32::NAN; 3]); + entry.points_low.push([0.0; 3]); continue; } + let pl = lw.points_low.get(idx).copied().unwrap_or([0.0; 3]); + // Reconstruct the f64 source from (high, low) before applying the + // insert transform — otherwise the low half is silently dropped. let v = accum_xform.apply(Vector3::new( - p[0] as f64 + lo_x, - p[1] as f64 + lo_y, - p[2] as f64 + lo_z, + p[0] as f64 + pl[0] as f64 + lo_x, + p[1] as f64 + pl[1] as f64 + lo_y, + p[2] as f64 + pl[2] as f64 + lo_z, )); - let q = [(v.x - ox) as f32, (v.y - oy) as f32, (v.z - oz) as f32]; - if q[0] < entry.min_x { - entry.min_x = q[0]; + let qx = (v.x - ox) as f32; + let qy = (v.y - oy) as f32; + let qz = (v.z - oz) as f32; + let qx_l = ((v.x - ox) - qx as f64) as f32; + let qy_l = ((v.y - oy) - qy as f64) as f32; + let qz_l = ((v.z - oz) - qz as f64) as f32; + let q = [qx, qy, qz]; + if qx < entry.min_x { + entry.min_x = qx; } - if q[1] < entry.min_y { - entry.min_y = q[1]; + if qy < entry.min_y { + entry.min_y = qy; } - if q[0] > entry.max_x { - entry.max_x = q[0]; + if qx > entry.max_x { + entry.max_x = qx; } - if q[1] > entry.max_y { - entry.max_y = q[1]; + if qy > entry.max_y { + entry.max_y = qy; } entry.points.push(q); + entry.points_low.push([qx_l, qy_l, qz_l]); } } diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 15d9d992e..031994d9e 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -24,6 +24,51 @@ use crate::scene::convert::truck_tess::{ }; use crate::scene::model::wire_model::{SnapHint, WireModel}; +/// Split an f64 offset-relative coordinate into the double-single (high, low) +/// f32 pair the renderer consumes. `high + low ≈ value` to ~f64 precision; the +/// RTE shader subtracts the eye's own high/low so vertices stay smooth even at +/// coordinates where a plain f32 cast would quantize to half a metre. +#[inline] +fn split_ds(v: f64) -> (f32, f32) { + let h = v as f32; + let l = (v - h as f64) as f32; + (h, l) +} + +#[inline] +fn split_ds_xyz(x: f64, y: f64, z: f64) -> ([f32; 3], [f32; 3]) { + let (xh, xl) = split_ds(x); + let (yh, yl) = split_ds(y); + let (zh, zl) = split_ds(z); + ([xh, yh, zh], [xl, yl, zl]) +} + +/// Subtract `world_offset` from each f64 source point and split the residual +/// into double-single (high, low) f32 buffers in one pass. +fn offset_to_ds( + src: impl IntoIterator, + world_offset: [f64; 3], +) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) { + let [ox, oy, oz] = world_offset; + let it = src.into_iter(); + let (lo, hi) = it.size_hint(); + let cap = hi.unwrap_or(lo); + let mut high = Vec::with_capacity(cap); + let mut low = Vec::with_capacity(cap); + for [x, y, z] in it { + if x.is_nan() { + // Wire-model NaN-separator: keep both buffers index-paired. + high.push([f32::NAN; 3]); + low.push([0.0; 3]); + continue; + } + let (h, l) = split_ds_xyz(x - ox, y - oy, z - oz); + high.push(h); + low.push(l); + } + (high, low) +} + // ── Public entry points ──────────────────────────────────────────────────── /// Tessellate one entity into a WireModel. @@ -104,7 +149,8 @@ pub fn tessellate( // value mixes inline colours. TruckObject::Text(stroke_groups) => { let [ox, oy, oz] = world_offset; - let elev = entity_z(entity) - oz as f32; + let entity_zf = entity_z(entity) as f64; + let elev_v = entity_zf - oz; // anno_scale anchors at the first group's origin so multi-line // MText lines spread apart correctly as they grow. @@ -112,46 +158,56 @@ pub fn tessellate( .first() .map(|g| g.origin) .unwrap_or([0.0, 0.0]); - let ref_lx = (ref_origin[0] - ox) as f32; - let ref_ly = (ref_origin[1] - oy) as f32; + let ref_lx_v = ref_origin[0] - ox; + let ref_ly_v = ref_origin[1] - oy; // Selection forces a single uniform colour — never split. let split_by_color = !selected; - // Bins: key = Some(rgb) or None (= inherit entity colour). - // Preserve insertion order so wire-emit ordering tracks the - // original glyph stream (useful for stable hit-test indexing). - let mut bins: Vec<(Option<[f32; 3]>, Vec<[f32; 3]>)> = Vec::new(); + // Bins: key = Some(rgb), parallel high/low f32 buffers — the + // low buffer is index-for-index with high so the renderer's + // double-single RTE shader survives at UTM-scale anchors. + let mut bins: Vec<(Option<[f32; 3]>, Vec<[f32; 3]>, Vec<[f32; 3]>)> = Vec::new(); let mut bin_first: Vec = Vec::new(); let find_or_make = - |key: Option<[f32; 3]>, bins: &mut Vec<(Option<[f32; 3]>, Vec<[f32; 3]>)>, firsts: &mut Vec| -> usize { - if let Some(i) = bins.iter().position(|(k, _)| *k == key) { + |key: Option<[f32; 3]>, bins: &mut Vec<(Option<[f32; 3]>, Vec<[f32; 3]>, Vec<[f32; 3]>)>, firsts: &mut Vec| -> usize { + if let Some(i) = bins.iter().position(|(k, _, _)| *k == key) { i } else { - bins.push((key, Vec::new())); + bins.push((key, Vec::new(), Vec::new())); firsts.push(true); bins.len() - 1 } }; + let anno = anno_scale as f64; for group in &stroke_groups { - let lx = (group.origin[0] - ox) as f32; - let ly = (group.origin[1] - oy) as f32; - let slx = (lx - ref_lx) * anno_scale + ref_lx; - let sly = (ly - ref_ly) * anno_scale + ref_ly; + let lx_v = group.origin[0] - ox; + let ly_v = group.origin[1] - oy; + let slx_v = (lx_v - ref_lx_v) * anno + ref_lx_v; + let sly_v = (ly_v - ref_ly_v) * anno + ref_ly_v; let bin_key = if split_by_color { group.color } else { None }; let bi = find_or_make(bin_key, &mut bins, &mut bin_first); - let pts = &mut bins[bi].1; + let (_k, pts, pts_low) = { + let b = &mut bins[bi]; + (&b.0, &mut b.1, &mut b.2) + }; + let _ = _k; for stroke in &group.strokes { if stroke.len() < 2 { continue; } if !bin_first[bi] && !pts.is_empty() { pts.push([f32::NAN, f32::NAN, f32::NAN]); + pts_low.push([0.0; 3]); } bin_first[bi] = false; for &[x, y] in stroke { - pts.push([x * anno_scale + slx, y * anno_scale + sly, elev]); + let xv = x as f64 * anno + slx_v; + let yv = y as f64 * anno + sly_v; + let (h, l) = split_ds_xyz(xv, yv, elev_v); + pts.push(h); + pts_low.push(l); } } } @@ -188,7 +244,7 @@ pub fn tessellate( let bin_count = bins.len(); let mut out: Vec = Vec::with_capacity(bin_count); - for (idx, (override_rgb, pts)) in bins.into_iter().enumerate() { + for (idx, (override_rgb, pts, pts_low)) in bins.into_iter().enumerate() { let wire_color = match override_rgb { Some([r, g, b]) => [r, g, b, color[3]], None => color, @@ -208,7 +264,7 @@ pub fn tessellate( out.push(WireModel { name: name.clone(), points: pts, - points_low: Vec::new(), + points_low: pts_low, color: wire_color, selected, pattern_length: 0.0, @@ -231,7 +287,7 @@ pub fn tessellate( TruckObject::Point(v) => { let result = tessellate_vertex(&v, world_offset); match result { - TruckTessResult::Point([x, y, z]) => { + TruckTessResult::Point([x, y, z], [xl, yl, zl]) => { let s = 0.1_f32; let snap_pts = offset_snap_pts(te.snap_pts, world_offset); let [ox, oy, oz] = world_offset; @@ -250,7 +306,11 @@ pub fn tessellate( [x, y - s, z], [x, y + s, z], ], - points_low: Vec::new(), + // All four cross points share the Point's residual + // (the cross arms are tiny, < 0.1 m, so the low + // component of the centre is also the right one + // for the arm tips at f32 precision). + points_low: vec![[xl, yl, zl]; 4], color, selected, pattern_length: 0.0, @@ -271,7 +331,9 @@ pub fn tessellate( } TruckObject::Curve(e) => { - if let TruckTessResult::Lines(points) = tessellate_edge(&e, world_offset) { + if let TruckTessResult::Lines(points, points_low) = + tessellate_edge(&e, world_offset) + { let [ox, oy, oz] = world_offset; let snap_pts = offset_snap_pts(te.snap_pts, world_offset); let key_vertices: Vec<[f32; 3]> = te @@ -282,7 +344,7 @@ pub fn tessellate( return vec![WireModel { name, points, - points_low: Vec::new(), + points_low, color, selected, pattern_length, @@ -301,7 +363,9 @@ pub fn tessellate( } TruckObject::Contour(w) => { - if let TruckTessResult::Lines(points) = tessellate_wire(&w, world_offset) { + if let TruckTessResult::Lines(points, points_low) = + tessellate_wire(&w, world_offset) + { let [ox, oy, oz] = world_offset; let snap_pts = offset_snap_pts(te.snap_pts, world_offset); let key_vertices: Vec<[f32; 3]> = te @@ -312,7 +376,7 @@ pub fn tessellate( return vec![WireModel { name, points, - points_low: Vec::new(), + points_low, color, selected, pattern_length, @@ -333,19 +397,12 @@ pub fn tessellate( TruckObject::Lines(points) => { // Points are world-space f64 from entity converters (polyline, // leader, mesh, solid2d, etc.). Subtract world_offset in f64 - // before casting to f32 so drawings at large UTM-style - // coordinates keep sub-unit precision in the wire model. + // and split into double-single (high, low) f32 buffers — the + // GPU shader pairs them so drawings at large UTM-style + // coordinates keep sub-unit precision in the wire model and + // don't jitter on camera movement. let [ox, oy, oz] = world_offset; - let local_pts: Vec<[f32; 3]> = points - .into_iter() - .map(|[x, y, z]| { - if x.is_nan() { - [f32::NAN, f32::NAN, f32::NAN] - } else { - [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32] - } - }) - .collect(); + let (local_pts, local_pts_low) = offset_to_ds(points, world_offset); let snap_pts = offset_snap_pts(te.snap_pts, world_offset); let key_vertices: Vec<[f32; 3]> = te .key_vertices @@ -360,7 +417,7 @@ pub fn tessellate( return vec![WireModel { name, points: local_pts, - points_low: Vec::new(), + points_low: local_pts_low, color, selected, pattern_length: 0.0, @@ -379,16 +436,7 @@ pub fn tessellate( TruckObject::SegmentedLines(points) => { let [ox, oy, oz] = world_offset; - let local_pts: Vec<[f32; 3]> = points - .into_iter() - .map(|[x, y, z]| { - if x.is_nan() { - [f32::NAN, f32::NAN, f32::NAN] - } else { - [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32] - } - }) - .collect(); + let (local_pts, local_pts_low) = offset_to_ds(points, world_offset); let snap_pts = offset_snap_pts(te.snap_pts, world_offset); let key_vertices: Vec<[f32; 3]> = te .key_vertices @@ -398,7 +446,7 @@ pub fn tessellate( return vec![WireModel { name, points: local_pts, - points_low: Vec::new(), + points_low: local_pts_low, color, selected, pattern_length, diff --git a/src/scene/convert/truck_tess.rs b/src/scene/convert/truck_tess.rs index af8c6fa64..185f8f258 100644 --- a/src/scene/convert/truck_tess.rs +++ b/src/scene/convert/truck_tess.rs @@ -72,10 +72,12 @@ pub(crate) fn active_curve_tol() -> Option { /// Output of any truck topology tessellation. #[allow(dead_code)] pub enum TruckTessResult { - /// A single world-space point (from Vertex). - Point([f32; 3]), - /// An ordered sequence of points forming a polyline (from Edge or Wire). - Lines(Vec<[f32; 3]>), + /// A single world-space point (from Vertex). Double-single (high, low). + Point([f32; 3], [f32; 3]), + /// An ordered sequence of points forming a polyline (from Edge or Wire), + /// returned as parallel double-single (high, low) f32 buffers so the GPU + /// keeps sub-unit precision at UTM-scale coordinates. + Lines(Vec<[f32; 3]>, Vec<[f32; 3]>), /// A triangle mesh (from Face, Shell, or Solid). Mesh { verts: Vec<[f32; 3]>, @@ -98,11 +100,25 @@ fn to_local(x: f64, y: f64, z: f64, off: [f64; 3]) -> [f32; 3] { ] } +/// Sub-f32 residual of `to_local`, computed in f64 — the renderer pairs this +/// with the high half so the double-single shader path reconstructs the f64 +/// source even when the high half is quantized to half a metre at UTM scale. +#[inline] +fn to_local_low(x: f64, y: f64, z: f64, off: [f64; 3], hi: [f32; 3]) -> [f32; 3] { + [ + ((x - off[0]) - hi[0] as f64) as f32, + ((y - off[1]) - hi[1] as f64) as f32, + ((z - off[2]) - hi[2] as f64) as f32, + ] +} + // ── Vertex ──────────────────────────────────────────────────────────────── pub fn tessellate_vertex(v: &Vertex, offset: [f64; 3]) -> TruckTessResult { let p = v.point(); - TruckTessResult::Point(to_local(p.x, p.y, p.z, offset)) + let hi = to_local(p.x, p.y, p.z, offset); + let lo = to_local_low(p.x, p.y, p.z, offset, hi); + TruckTessResult::Point(hi, lo) } // ── Edge ────────────────────────────────────────────────────────────────── @@ -115,29 +131,36 @@ pub fn tessellate_edge(e: &Edge, offset: [f64; 3]) -> TruckTessResult { // more on tight curves, all within the active chord-height tolerance. // The Scene scales this with zoom so far-out arcs aren't oversampled. let (_, pts) = curve.parameter_division((t0, t1), current_curve_tol()); - let lines = pts - .iter() - .map(|p| to_local(p.x, p.y, p.z, offset)) - .collect(); - TruckTessResult::Lines(lines) + let mut high: Vec<[f32; 3]> = Vec::with_capacity(pts.len()); + let mut low: Vec<[f32; 3]> = Vec::with_capacity(pts.len()); + for p in &pts { + let hi = to_local(p.x, p.y, p.z, offset); + let lo = to_local_low(p.x, p.y, p.z, offset, hi); + high.push(hi); + low.push(lo); + } + TruckTessResult::Lines(high, low) } // ── Wire ────────────────────────────────────────────────────────────────── pub fn tessellate_wire(w: &Wire, offset: [f64; 3]) -> TruckTessResult { - let mut pts: Vec<[f32; 3]> = Vec::new(); + let mut high: Vec<[f32; 3]> = Vec::new(); + let mut low: Vec<[f32; 3]> = Vec::new(); for edge in w.edge_iter() { - if let TruckTessResult::Lines(ep) = tessellate_edge(edge, offset) { - if pts.is_empty() { - pts = ep; + if let TruckTessResult::Lines(eh, el) = tessellate_edge(edge, offset) { + if high.is_empty() { + high = eh; + low = el; } else { // Skip the first point of each continuation edge to avoid // duplicating the shared junction vertex. - pts.extend_from_slice(&ep[1..]); + high.extend_from_slice(&eh[1..]); + low.extend_from_slice(&el[1..]); } } } - TruckTessResult::Lines(pts) + TruckTessResult::Lines(high, low) } // ── Shell ───────────────────────────────────────────────────────────────── diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 6be1d4535..7c0d42ba9 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -104,13 +104,22 @@ pub fn edge_wires(solid: &Solid) -> Vec { let mut wires = Vec::new(); for shell in solid.boundaries() { for edge in shell.edge_iter() { - if let TruckTessResult::Lines(pts) = tessellate_edge(&edge, [0.0; 3]) { + if let TruckTessResult::Lines(pts, pts_low) = + tessellate_edge(&edge, [0.0; 3]) + { if pts.len() < 2 { continue; } let pts3: Vec = pts .iter() - .map(|p| Vector3::new(p[0] as f64, p[1] as f64, p[2] as f64)) + .zip(pts_low.iter()) + .map(|(p, l)| { + Vector3::new( + p[0] as f64 + l[0] as f64, + p[1] as f64 + l[1] as f64, + p[2] as f64 + l[2] as f64, + ) + }) .collect(); wires.push(acadrust::entities::Wire::from_points(pts3)); } diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 90da71bfb..b942dd07c 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -9,18 +9,21 @@ // WireModel, separated by [NaN, NaN, NaN] points. Segments where either // endpoint contains NaN are silently skipped during emission. // -// Instance layout (76 bytes, stride = 76, step_mode = Instance): -// pos_a [f32; 3] offset 0 12 B — segment start (world) -// pos_b [f32; 3] offset 12 12 B — segment end (world) -// color [u8; 4] offset 24 4 B — RGBA, Unorm8x4 → vec4 in shader -// distance_a f32 offset 28 4 B — arc-length at endpoint A -// distance_b f32 offset 32 4 B — arc-length at endpoint B -// half_width f32 offset 36 4 B — half line width in pixels -// pattern_length f32 offset 40 4 B — dash pattern total length -// pat0 [f32; 4] offset 44 16 B — pattern elements 0-3 -// pat1 [f32; 4] offset 60 16 B — pattern elements 4-7 -// ------ -// 76 B / instance +// Instance layout (step_mode = Instance): +// pos_a [f32; 3] — segment start (high half, world / offset-relative) +// pos_a_low [f32; 3] — segment start low residual (double-single pair) +// pos_b [f32; 3] — segment end (high) +// pos_b_low [f32; 3] — segment end low residual +// color [u8; 4] — RGBA, Unorm8x4 → vec4 in shader +// distance_a f32 — arc-length at endpoint A +// distance_b f32 — arc-length at endpoint B +// half_width f32 — half line width in pixels +// pattern_length f32 — dash pattern total length +// pat0 [f32; 4] — pattern elements 0-3 +// pat1 [f32; 4] — pattern elements 4-7 +// draw_depth f32 — normalized draw-order depth bias +// The high+low pair encodes the f64 source so the relative-to-eye shader +// stays precise at UTM-scale coordinates and after a cross-drawing paste. use crate::scene::model::wire_model::WireModel; use iced::wgpu; @@ -61,7 +64,9 @@ fn instance_buffer_mapped( #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct WireInstance { pub pos_a: [f32; 3], + pub pos_a_low: [f32; 3], pub pos_b: [f32; 3], + pub pos_b_low: [f32; 3], /// RGBA packed as `Unorm8x4` — the vertex shader receives a `vec4` /// in [0, 1] after the GPU does the conversion. 8 bits per channel is /// indistinguishable from f32 at 8-bit display output. @@ -79,61 +84,27 @@ pub struct WireInstance { impl WireInstance { pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { + // Offsets come from the struct layout (must match the shader location + // indices in wire.wgsl). The low residuals are appended at locations + // 10/11 so the existing 0-9 stay stable. + const ATTRS: &[wgpu::VertexAttribute] = &[ + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pos_a) as u64, shader_location: 0, format: wgpu::VertexFormat::Float32x3 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pos_b) as u64, shader_location: 1, format: wgpu::VertexFormat::Float32x3 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, color) as u64, shader_location: 2, format: wgpu::VertexFormat::Unorm8x4 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_a) as u64, shader_location: 3, format: wgpu::VertexFormat::Float32 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_b) as u64, shader_location: 4, format: wgpu::VertexFormat::Float32 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, half_width) as u64, shader_location: 5, format: wgpu::VertexFormat::Float32 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pattern_length) as u64, shader_location: 6, format: wgpu::VertexFormat::Float32 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pat0) as u64, shader_location: 7, format: wgpu::VertexFormat::Float32x4 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pat1) as u64, shader_location: 8, format: wgpu::VertexFormat::Float32x4 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, draw_depth) as u64, shader_location: 9, format: wgpu::VertexFormat::Float32 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pos_a_low) as u64, shader_location: 10, format: wgpu::VertexFormat::Float32x3 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pos_b_low) as u64, shader_location: 11, format: wgpu::VertexFormat::Float32x3 }, + ]; wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, step_mode: wgpu::VertexStepMode::Instance, - attributes: &[ - wgpu::VertexAttribute { - offset: 0, - shader_location: 0, - format: wgpu::VertexFormat::Float32x3, - }, // pos_a - wgpu::VertexAttribute { - offset: 12, - shader_location: 1, - format: wgpu::VertexFormat::Float32x3, - }, // pos_b - wgpu::VertexAttribute { - offset: 24, - shader_location: 2, - format: wgpu::VertexFormat::Unorm8x4, - }, // color - wgpu::VertexAttribute { - offset: 28, - shader_location: 3, - format: wgpu::VertexFormat::Float32, - }, // distance_a - wgpu::VertexAttribute { - offset: 32, - shader_location: 4, - format: wgpu::VertexFormat::Float32, - }, // distance_b - wgpu::VertexAttribute { - offset: 36, - shader_location: 5, - format: wgpu::VertexFormat::Float32, - }, // half_width - wgpu::VertexAttribute { - offset: 40, - shader_location: 6, - format: wgpu::VertexFormat::Float32, - }, // pattern_length - wgpu::VertexAttribute { - offset: 44, - shader_location: 7, - format: wgpu::VertexFormat::Float32x4, - }, // pat0 - wgpu::VertexAttribute { - offset: 60, - shader_location: 8, - format: wgpu::VertexFormat::Float32x4, - }, // pat1 - wgpu::VertexAttribute { - offset: 76, - shader_location: 9, - format: wgpu::VertexFormat::Float32, - }, // draw_depth - ], + attributes: ATTRS, } } } @@ -236,6 +207,10 @@ fn emit_wire_instances(wire: &WireModel, color: [f32; 4], draw_depth: f32) -> Ve } } + // Low residual paired index-for-index with points; empty → all-zero + // (interactive / preview wires that don't need sub-f32 precision). + let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; + let mut instances: Vec = Vec::with_capacity(seg_count); for i in 0..seg_count { let a = wire.points[i]; @@ -251,7 +226,9 @@ fn emit_wire_instances(wire: &WireModel, color: [f32; 4], draw_depth: f32) -> Ve } instances.push(WireInstance { pos_a: a, + pos_a_low: low(i), pos_b: b, + pos_b_low: low(i + 1), color: color_u8, distance_a: dists[i], distance_b: dists[i + 1], diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index 63ac1daa7..b00a0a72a 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -53,6 +53,9 @@ struct InstanceIn { @location(7) pat0: vec4, @location(8) pat1: vec4, @location(9) draw_depth: f32, + // Double-single low residuals of the endpoints. + @location(10) pos_a_low: vec3, + @location(11) pos_b_low: vec3, } // Draw-order depth bias: shifts clip-space z so 2D entities of different @@ -85,13 +88,14 @@ struct VertexOut { let which_end = which_end_arr[vid]; let side = side_arr[vid]; - // Relative-to-eye: subtract the eye (high+low) from each endpoint, then - // transform by the rotation-only view-projection. (pos − eye_high) is exact - // in f32 when both are the same magnitude (Sterbenz), and eye_low corrects - // the eye's sub-f32 residual — so the position tracks the camera smoothly - // at large coordinates instead of snapping onto the f32 grid each frame. - let rel_a = (in.pos_a - u.eye_high) - u.eye_low; - let rel_b = (in.pos_b - u.eye_high) - u.eye_low; + // Double-single relative-to-eye: subtract the eye from each endpoint with + // both halves of the f64-emulating pair, then transform by the rotation-only + // view-projection. (pos_high − eye_high) is exact in f32 for same-magnitude + // operands (Sterbenz); adding (pos_low − eye_low) restores the residual both + // the vertex and the eye would otherwise lose — so geometry stays put at + // UTM-scale coordinates and after a cross-drawing paste, with no jitter. + let rel_a = (in.pos_a - u.eye_high) + (in.pos_a_low - u.eye_low); + let rel_b = (in.pos_b - u.eye_high) + (in.pos_b_low - u.eye_low); let clip_a = u.view_rot * vec4(rel_a, 1.0); let clip_b = u.view_rot * vec4(rel_b, 1.0);