feat(render): GPU analytical rendering for circles, arcs, and ellipses

- Render planar circles, circular arcs, ellipses, and elliptical arcs analytically via GPU-instanced screen-space quads and WGSL shaders.
- Implement exact arc sweep clamping with round end caps matching the CAD wire shader.
- Implement robust Newton-Raphson closest-point solver for ellipses with isotropic screen-space derivative anti-aliasing.
- Integrate into wire pipeline and PersistentWireArena upload paths.
- Remove unused mesh_volume_centroid function in doc_api.
This commit is contained in:
Karim Jerbi committed 2026-09-07 13:50:12 +01:00
1 parent 143703d809
commit 0425f979f9
17 files changed
+1860 -40

No files matched your search

+1 -9
View File
@@ -999,14 +999,6 @@ fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement {
}
}
/// Signed volume and centroid of a closed triangle mesh via the divergence
/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source
/// of truth for both (used by the cold-cache `volume`/`centroid` fallback).
fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) {
// Delegate to the crate's single source of truth (divergence theorem).
ocs_doc_api::geom::mesh_volume_centroid(mesh)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -3235,7 +3227,7 @@ mod tests {
let body = crate::scene::convert::solid3d_tess::kernel_body(solid)
.expect("re-lift reloaded solid failed");
let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3);
let vol = mesh_volume_centroid(&mesh).0;
let vol = ocs_doc_api::geom::mesh_volume_centroid(&mesh).0;
assert!(
(vol - 125.0).abs() < 1.0,
"reloaded intersection volume {vol}"
+145 -3
View File
@@ -10,7 +10,39 @@ use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, R
use crate::scene::model::object::{GripApply, GripDef, PropSection};
use crate::scene::model::wire_model::TangentGeom;
fn to_render(circle: &Circle) -> RenderEntity {
/// The number of segments to tessellate a circle into, based on its radius and the
/// current viewport's world units per pixel (`wpp`).
///
/// Targets a maximum chord sagitta of ~0.25 pixels on screen so circles stay visually
/// round at any zoom level, while bounded between a minimum of 48 (preventing
/// coarse polygons when small or far away) and a maximum of 4096 (capping buffer size
/// on extreme zoom).
pub fn circle_segments(radius: f64, wpp: Option<f32>) -> usize {
const MIN_SEGMENTS: usize = 48;
const MAX_SEGMENTS: usize = 4096;
let Some(w) = wpp.filter(|&w| w.is_finite() && w > 0.0) else {
return MIN_SEGMENTS;
};
let r = radius.abs();
if !r.is_finite() || r <= 0.0 {
return MIN_SEGMENTS;
}
// Target chord sagitta of ~0.25 px on screen:
// sag = r * (1 - cos(theta / 2)) <= tolerance
let tolerance = (w as f64) * 0.25;
if r <= tolerance {
return MIN_SEGMENTS;
}
let cos_half = (1.0 - tolerance / r).clamp(-1.0, 1.0);
let step = 2.0 * cos_half.acos();
if step <= 0.0 {
return MAX_SEGMENTS;
}
let n = (std::f64::consts::TAU / step).ceil() as usize;
n.clamp(MIN_SEGMENTS, MAX_SEGMENTS)
}
pub fn to_render_with_wpp(circle: &Circle, wpp: Option<f32>) -> RenderEntity {
let cx = circle.center.x;
let cy = circle.center.y;
let cz = circle.center.z;
@@ -31,10 +63,12 @@ fn to_render(circle: &Circle) -> RenderEntity {
radius: r,
};
let n = circle_segments(r, wpp);
if circle.thickness.abs() > 1e-10 {
let t = circle.thickness;
let (nx, ny, nz) = normal;
let n = 64usize;
let n = n.max(64);
let tau = std::f64::consts::TAU;
let circ_pt = |a: f64| -> (f64, f64, f64) {
let (c, s) = (a.cos(), a.sin());
@@ -76,7 +110,8 @@ fn to_render(circle: &Circle) -> RenderEntity {
}
// Keep tessellation on the entity curve for large-coordinate precision.
let pts = crate::entities::curve::curve_points(&curve);
let max_angle = std::f64::consts::TAU / (n as f64);
let pts = curve.tessellate_angle(max_angle);
RenderEntity {
pick_tris: Vec::new(),
@@ -88,6 +123,22 @@ fn to_render(circle: &Circle) -> RenderEntity {
}
}
fn to_render(circle: &Circle) -> RenderEntity {
to_render_with_wpp(circle, None)
}
/// Zoom-aware render converter for EntityType::Circle.
pub fn relative_render(
entity: &acadrust::EntityType,
_document: &acadrust::CadDocument,
wpp: Option<f32>,
) -> Option<RenderEntity> {
let acadrust::EntityType::Circle(circle) = entity else {
return None;
};
Some(to_render_with_wpp(circle, wpp))
}
fn grips(circle: &Circle) -> Vec<GripDef> {
let center = circle.center_wcs();
let (axis_x, axis_y) = circle.axes_wcs();
@@ -220,3 +271,94 @@ impl crate::entities::traits::MassPropsCalc for Circle {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_circle_segments_scales_with_zoom() {
let r = 10.0;
// Default / invalid cases return baseline floor of 48.
assert_eq!(circle_segments(r, None), 48);
assert_eq!(circle_segments(r, Some(-1.0)), 48);
assert_eq!(circle_segments(r, Some(0.0)), 48);
assert_eq!(circle_segments(r, Some(f32::NAN)), 48);
assert_eq!(circle_segments(r, Some(f32::INFINITY)), 48);
// Zoomed far out: screen radius is tiny, capped at floor 48.
assert_eq!(circle_segments(r, Some(10.0)), 48);
assert_eq!(circle_segments(r, Some(2.0)), 48);
// Zooming closer increases segments monotonically.
let seg_0_1 = circle_segments(r, Some(0.1));
let seg_0_01 = circle_segments(r, Some(0.01));
let seg_0_001 = circle_segments(r, Some(0.001));
let seg_0_0001 = circle_segments(r, Some(0.0001));
assert!(seg_0_1 >= 48, "seg_0_1 was {seg_0_1}");
assert!(seg_0_01 > seg_0_1, "seg_0_01 ({seg_0_01}) <= seg_0_1 ({seg_0_1})");
assert!(seg_0_001 > seg_0_01, "seg_0_001 ({seg_0_001}) <= seg_0_01 ({seg_0_01})");
assert!(seg_0_0001 > seg_0_001, "seg_0_0001 ({seg_0_0001}) <= seg_0_001 ({seg_0_001})");
// Extreme zoom in hits maximum cap of 4096.
assert_eq!(circle_segments(r, Some(1e-9)), 4096);
}
#[test]
fn test_circle_segments_scales_with_radius() {
let wpp = Some(0.1);
let seg_small = circle_segments(1.0, wpp);
let seg_med = circle_segments(20.0, wpp);
let seg_large = circle_segments(100.0, wpp);
let seg_huge = circle_segments(500.0, wpp);
assert_eq!(seg_small, 48);
assert!(seg_med > seg_small, "{seg_med} <= {seg_small}");
assert!(seg_large > seg_med, "{seg_large} <= {seg_med}");
assert!(seg_huge > seg_large, "{seg_huge} <= {seg_large}");
}
#[test]
fn test_circle_to_render_points_increase_with_zoom() {
let mut circle = Circle::default();
circle.radius = 10.0;
let far_render = to_render_with_wpp(&circle, Some(1.0));
let close_render = to_render_with_wpp(&circle, Some(0.01));
let far_count = match far_render.object {
RenderObject::Lines(pts) => pts.len(),
_ => panic!("Expected RenderObject::Lines"),
};
let close_count = match close_render.object {
RenderObject::Lines(pts) => pts.len(),
_ => panic!("Expected RenderObject::Lines"),
};
assert_eq!(far_count, 49); // 48 segments + 1 closing vertex
assert!(close_count > far_count, "close_count was {close_count} vs far_count {far_count}");
}
#[test]
fn test_thick_circle_to_render_points_increase_with_zoom() {
let mut circle = Circle::default();
circle.radius = 10.0;
circle.thickness = 2.0;
let far_render = to_render_with_wpp(&circle, Some(1.0));
let close_render = to_render_with_wpp(&circle, Some(0.01));
let far_count = match far_render.object {
RenderObject::Lines(pts) => pts.len(),
_ => panic!("Expected RenderObject::Lines"),
};
let close_count = match close_render.object {
RenderObject::Lines(pts) => pts.len(),
_ => panic!("Expected RenderObject::Lines"),
};
assert!(close_count > far_count, "{close_count} <= {far_count}");
}
}
+15
View File
@@ -5408,6 +5408,21 @@ fn map_wire_ocs_to_wcs(wire: &mut WireModel, normal: Vector3) {
*axis_x = map_vector(*axis_x);
*axis_y = map_vector(*axis_y);
}
TangentGeom::PlanarEllipse {
center,
major_axis,
normal,
..
} => {
let mapped = map(center[0], center[1], center[2]);
let map_vector = |axis: [f64; 3]| {
let mapped = map(axis[0], axis[1], axis[2]);
[mapped.0, mapped.1, mapped.2]
};
*center = [mapped.0, mapped.1, mapped.2];
*major_axis = map_vector(*major_axis);
*normal = map_vector(*normal);
}
}
}
wire.aabb = WireModel::UNBOUNDED_AABB;
+19 -9
View File
@@ -15,6 +15,7 @@ use crate::entities::common::{
use crate::entities::traits::RenderConvertible;
use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection};
use crate::scene::model::wire_model::TangentGeom;
fn to_render(ell: &Ellipse) -> RenderEntity {
// ELLIPSE is one of the few WCS entities in DXF: `center` (code 10) and
@@ -29,6 +30,15 @@ fn to_render(ell: &Ellipse) -> RenderEntity {
.map(crate::entities::curve::snap_from)
.unwrap_or_default();
let tangent = TangentGeom::PlanarEllipse {
center: [ell.center.x, ell.center.y, ell.center.z],
major_axis: [ell.major_axis.x, ell.major_axis.y, ell.major_axis.z],
normal: [ell.normal.x, ell.normal.y, ell.normal.z],
minor_axis_ratio: ell.minor_axis_ratio,
start_param: ell.start_parameter,
end_param: ell.end_parameter,
};
// The points come from the entity's own kernel curve and angular policy.
//
// What does not change is the shape of the object. EXTRUDE, REVOLVE and
@@ -39,7 +49,7 @@ fn to_render(ell: &Ellipse) -> RenderEntity {
pick_tris: Vec::new(),
object: RenderObject::Lines(Vec::new()),
snap_pts: snap.snap_pts,
tangent_geoms: vec![],
tangent_geoms: vec![tangent.clone()],
key_vertices: vec![],
fill_tris: vec![],
};
@@ -57,7 +67,7 @@ fn to_render(ell: &Ellipse) -> RenderEntity {
pick_tris: Vec::new(),
object,
snap_pts: snap.snap_pts,
tangent_geoms: vec![],
tangent_geoms: vec![tangent],
key_vertices: vec![],
fill_tris: vec![],
}
@@ -284,16 +294,16 @@ fn apply_axis_grip(ell: &mut Ellipse, grip_id: usize, point: glam::DVec3) {
fn apply_grip(ell: &mut Ellipse, grip_id: usize, apply: GripApply) {
match (grip_id, apply) {
(0, GripApply::Translate(d)) => {
ell.center.x += d.x as f64;
ell.center.y += d.y as f64;
ell.center.z += d.z as f64;
ell.center.x += d.x;
ell.center.y += d.y;
ell.center.z += d.z;
}
(0, GripApply::Absolute(p)) => {
ell.center.x = p.x as f64;
ell.center.y = p.y as f64;
ell.center.z = p.z as f64;
ell.center.x = p.x;
ell.center.y = p.y;
ell.center.z = p.z;
}
(1 | 2 | 3 | 4, GripApply::Absolute(p)) => apply_axis_grip(ell, grip_id, p),
(1..=4, GripApply::Absolute(p)) => apply_axis_grip(ell, grip_id, p),
(5 | 6, GripApply::Absolute(p)) => {
let Some(parameter) = parameter_at_point(ell, p) else {
return;
+27 -1
View File
@@ -1268,7 +1268,8 @@ fn translated_prototype_wire(
}
}
TangentGeom::PlanarCircle { center, .. }
| TangentGeom::Arc { center, .. } => {
| TangentGeom::Arc { center, .. }
| TangentGeom::PlanarEllipse { center, .. } => {
for axis in 0..3 {
center[axis] += delta[axis];
}
@@ -2727,6 +2728,31 @@ fn transform_tangent(
radius: radius * ((sx + sy) * 0.5),
})
}
TangentGeom::PlanarEllipse {
center,
major_axis,
normal,
minor_axis_ratio,
start_param,
end_param,
} => {
let c = t.apply(Vector3::new(center[0], center[1], center[2]));
let m = t.apply_rotation(Vector3::new(major_axis[0], major_axis[1], major_axis[2]));
let n = t.apply_rotation(Vector3::new(normal[0], normal[1], normal[2]));
let n_len = n.length();
if n_len <= 1.0e-12 {
return None;
}
let n = n / n_len;
Some(TangentGeom::PlanarEllipse {
center: [c.x, c.y, c.z],
major_axis: [m.x, m.y, m.z],
normal: [n.x, n.y, n.z],
minor_axis_ratio: *minor_axis_ratio,
start_param: *start_param,
end_param: *end_param,
})
}
}
}
+3 -1
View File
@@ -707,9 +707,11 @@ pub fn tessellate(
}
// ── Try the kernel path first ───────────────────────────────────────────
// Zoom-adaptive circles scale their segment count from the current zoom level and radius.
// Relative-PDSIZE points size their glyph from the current zoom so they
// stay a roughly constant on-screen size; otherwise the header-driven path.
let te = crate::entities::point::relative_render(entity, document, world_per_pixel)
let te = crate::entities::circle::relative_render(entity, document, world_per_pixel)
.or_else(|| crate::entities::point::relative_render(entity, document, world_per_pixel))
.or_else(|| crate::entities::light::relative_render(entity, document, world_per_pixel))
.or_else(|| match entity {
EntityType::Text(text) => Some(crate::entities::text::to_render_at_scale(
+77 -9
View File
@@ -2232,6 +2232,7 @@ impl Scene {
self.annotation_all_visible(),
None,
None,
None,
);
let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed);
self.last_model_wire_gen.set(gen);
@@ -5161,9 +5162,9 @@ impl Scene {
pub(super) fn model_tile_wires_arc(
&self,
_tile_idx: usize,
_cam: &Camera,
cam: &Camera,
_cam_aspect: f32,
_tile_pixel_height: f32,
tile_pixel_height: f32,
) -> Arc<Vec<WireModel>> {
// In a BEDIT block editor the resident set is the edited block's own
// (block-local) entities; otherwise model space. (#261)
@@ -5174,7 +5175,12 @@ impl Scene {
&self.document,
&self.document.header.current_annotation_scale,
);
self.resident_wires_for(block, None, scale, None, None)
let wpp = if tile_pixel_height > 0.0 {
Some((2.0 * cam.ortho_size()) / tile_pixel_height)
} else {
self.world_per_pixel()
};
self.resident_wires_for(block, None, scale, None, None, wpp)
}
/// Unified static-hold wire builder — the ONE tessellation path every
@@ -5223,6 +5229,7 @@ impl Scene {
annotation_scale_handle: Option<Handle>,
frozen_layers: Option<&HashSet<Handle>>,
style_viewport: Option<Handle>,
wpp: Option<f32>,
) -> Arc<Vec<WireModel>> {
// Normalize an inert anno override away so distinct viewport scales
// share one resident set when annotation can't change the wires.
@@ -5239,6 +5246,7 @@ impl Scene {
self.paper_bg_color
};
let all_visible = self.annotation_all_visible();
let quantized_wpp = Self::quantize_wpp(wpp);
let key = self.resident_wire_key(
block,
bg,
@@ -5247,6 +5255,7 @@ impl Scene {
all_visible,
frozen_layers,
style_viewport,
quantized_wpp,
);
{
let sets = self.resident_wire_sets.borrow();
@@ -5271,17 +5280,18 @@ impl Scene {
all_visible,
frozen_layers,
style_viewport,
quantized_wpp,
)
{
return arc;
}
// Build once: full tessellation, no cull (region = None), no zoom LOD
// (wpp = None) — for every space, exactly like the Model static-hold.
// Build once: full tessellation, no cull (region = None), zoom LOD
// bounded by quantized_wpp for every space.
let t_tess = iced::time::Instant::now();
let mut wires = self.wires_for_block_culled(
block,
None,
None,
quantized_wpp,
frozen_layers,
anno_scale_override,
annotation_scale_handle,
@@ -5327,6 +5337,9 @@ impl Scene {
// spaces or re-scaling a viewport can't accumulate dead full sets.
let cur_epoch = self.geometry_epoch;
sets.retain(|_, set| set.epoch == cur_epoch);
if sets.len() > 16 {
sets.clear();
}
sets.insert(
key,
ResidentWireSet {
@@ -5348,6 +5361,7 @@ impl Scene {
all_visible: bool,
frozen_layers: Option<&HashSet<Handle>>,
style_viewport: Option<Handle>,
quantized_wpp: Option<f32>,
) -> u64 {
let mut key: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix =
@@ -5362,6 +5376,9 @@ impl Scene {
mix(annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0));
mix(all_visible as u64);
mix(self.viewport_style_key(style_viewport));
mix(quantized_wpp
.map(|w| w.to_bits() as u64)
.unwrap_or(u64::MAX));
match frozen_layers {
Some(frozen) => {
let mut signature = 0u64;
@@ -5376,6 +5393,18 @@ impl Scene {
key
}
/// Quantize world units per pixel (`wpp`) into half-octave bands (~1.414x zoom ratio).
///
/// This provides discrete, stable zoom bands so that panning (where wpp is constant)
/// and small subpixel viewport drifts or mouse-wheel motions within a band do not
/// cause re-tessellation or GPU buffer re-uploads, while zooming past a band threshold
/// smoothly scales circle tessellation detail.
pub fn quantize_wpp(wpp: Option<f32>) -> Option<f32> {
let w = wpp.filter(|&w| w.is_finite() && w > 0.0)?;
let step = (w.log2() * 2.0).floor() * 0.5;
Some(2.0f32.powf(step))
}
/// The GPU wire-arena handoff for a viewport whose content id is `gen`:
/// `(prev_gen, changed handles)` when the Model set reached `gen` via an
/// incremental resident patch, else `None`. Read (not consumed) so every
@@ -5462,6 +5491,7 @@ impl Scene {
all_visible: bool,
frozen_layers: Option<&HashSet<Handle>>,
style_viewport: Option<Handle>,
quantized_wpp: Option<f32>,
) -> Option<Arc<Vec<WireModel>>> {
if self.viewport_style_key(style_viewport) != 0 {
return None;
@@ -5545,7 +5575,7 @@ impl Scene {
e,
Some(&blk),
None,
None,
quantized_wpp,
anno_scale_override.is_some(),
);
memo_updates.push((*h, Arc::new(raw.clone())));
@@ -5827,7 +5857,7 @@ impl Scene {
}
let layout_block = self.current_layout_block_handle();
let scale = self.paper_annotation_scale_handle();
let base = self.resident_wires_for(layout_block, None, scale, None, None);
let base = self.resident_wires_for(layout_block, None, scale, None, None, None);
let mut wires = (*base).clone();
// The overall "sheet" viewport now IS the paper view itself, so its own
// border rectangle must not be drawn as an entity on the sheet.
@@ -9061,7 +9091,7 @@ impl Scene {
static EN: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*EN.get_or_init(|| std::env::var("OCS_NO_RESIDENT_MEMO").is_err())
}
let resident = base_ok && view_aabb.is_none() && wpp.is_none() && resident_memo_enabled();
let resident = base_ok && view_aabb.is_none() && resident_memo_enabled();
let memo_active = base_ok && (view_aabb.is_some() || resident);
// Reported in one line below. `tess_ms` / `memo_misses` are assigned by
// both branches; the rest exist only on the memo path and stay zero.
@@ -10200,6 +10230,7 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}",
scale,
None,
None,
None,
);
let wire_points = wires.iter().flat_map(|wire| wire.key_vertices.iter().copied());
let mesh_points = self.meshes.iter().filter_map(|(&handle, set)| {
@@ -11213,6 +11244,7 @@ mod layout_cache_tests {
scale,
None,
None,
None,
);
let generation = s.last_model_wire_gen.get();
let bounds = s.model_space_extents().unwrap();
@@ -11226,4 +11258,40 @@ mod layout_cache_tests {
assert!(!source.is_empty());
}
}
#[test]
fn circle_wires_scale_with_zoom_level() {
let mut s = Scene::new();
let mut circle = acadrust::entities::Circle::default();
circle.radius = 100.0;
let handle = s.add_entity(EntityType::Circle(circle));
// Far camera: distance is large -> wpp is large
let mut cam_far = Camera::default();
cam_far.distance = 1000.0;
// Close camera: distance is small -> wpp is small
let mut cam_close = Camera::default();
cam_close.distance = 1.0;
let wires_far = s.model_tile_wires_arc(0, &cam_far, 1.0, 1000.0);
let circle_wire_far = wires_far
.iter()
.find(|w| w.name == handle.value().to_string())
.unwrap();
let far_pts = circle_wire_far.points.len();
let wires_close = s.model_tile_wires_arc(0, &cam_close, 1.0, 1000.0);
let circle_wire_close = wires_close
.iter()
.find(|w| w.name == handle.value().to_string())
.unwrap();
let close_pts = circle_wire_close.points.len();
assert!(
close_pts > far_pts,
"Close camera points ({close_pts}) should exceed far camera points ({far_pts})"
);
assert!(close_pts > 500, "Close points ({close_pts}) should be > 500");
}
}
+9
View File
@@ -47,6 +47,15 @@ pub enum TangentGeom {
start_angle: f64,
end_angle: f64,
},
/// Ellipse or elliptical arc in an arbitrary world-space plane.
PlanarEllipse {
center: [f64; 3],
major_axis: [f64; 3],
normal: [f64; 3],
minor_axis_ratio: f64,
start_param: f64,
end_param: f64,
},
}
/// Viewport-relative display transform for a point marker.
+10 -4
View File
@@ -819,7 +819,7 @@ impl Scene {
fn model_wires_for_viewport(
&self,
vp_handle: Handle,
_screen_height_px: f32,
screen_height_px: f32,
) -> Arc<Vec<WireModel>> {
use rustc_hash::FxHashSet as HSet;
@@ -829,12 +829,17 @@ impl Scene {
// wheel tick whenever the drawing contained one annotative object.
// Explicit viewport annotation-scale changes still rebuild the resident
// set; PSLTSCALE is a viewport GPU uniform.
let frozen = match self.document.get_entity(vp_handle) {
let (frozen, wpp) = match self.document.get_entity(vp_handle) {
Some(EntityType::Viewport(vp)) => {
let f: HSet<Handle> = vp.frozen_layers.iter().cloned().collect();
f
let w = if screen_height_px > 0.0 && vp.view_height > 0.0 {
Some((vp.view_height as f32) / screen_height_px)
} else {
None
};
(f, w)
}
_ => HSet::default(),
_ => (HSet::default(), None),
};
let scale_handle = self.viewport_scale_handle(vp_handle);
@@ -844,6 +849,7 @@ impl Scene {
scale_handle,
Some(&frozen),
Some(vp_handle),
wpp,
)
}
+374
View File
@@ -0,0 +1,374 @@
//! GPU-instanced analytical circle buffer & pipeline layout.
//!
//! Renders 2D/planar circles as analytical screen/plane-aligned quads with
//! screen-space derivative anti-aliasing. Each circle is a single instance
//! (6 vertices for a 2-triangle quad), eliminating CPU re-tessellation on zoom
//! and providing infinite visual smoothness.
use iced::wgpu;
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct CircleInstance {
pub center_high: [f32; 3],
pub _pad0: f32,
pub center_low: [f32; 3],
pub radius: f32,
pub axis_x: [f32; 3],
pub start_angle: f32,
pub axis_y: [f32; 3],
pub end_angle: f32,
pub color: [f32; 4],
/// `[half_width, pattern_length, draw_depth, padding]`
pub params: [f32; 4],
pub pat0: [f32; 4],
pub pat1: [f32; 4],
}
impl CircleInstance {
pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
const ATTRS: &[wgpu::VertexAttribute] = &[
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, center_high) as u64,
shader_location: 0,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, center_low) as u64,
shader_location: 1,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, axis_x) as u64,
shader_location: 2,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, axis_y) as u64,
shader_location: 3,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, color) as u64,
shader_location: 4,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, params) as u64,
shader_location: 5,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, pat0) as u64,
shader_location: 6,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, pat1) as u64,
shader_location: 7,
format: wgpu::VertexFormat::Float32x4,
},
];
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<CircleInstance>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: ATTRS,
}
}
}
pub struct CircleGpu {
pub instance_buffer: wgpu::Buffer,
pub instance_count: u32,
}
impl CircleGpu {
pub fn from_instances(
device: &wgpu::Device,
queue: &wgpu::Queue,
label: &str,
instances: &[CircleInstance],
) -> Self {
let instance_buffer = super::gpu_upload::upload_buffer(
device,
queue,
label,
instances,
wgpu::BufferUsages::VERTEX,
);
Self {
instance_buffer,
instance_count: instances.len() as u32,
}
}
}
/// Build the base and xray circle pipelines.
pub fn create_pipelines(
device: &wgpu::Device,
frame_bgl: &wgpu::BindGroupLayout,
color_format: wgpu::TextureFormat,
sample_count: u32,
content_stencil: &wgpu::StencilState,
) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("circle.wgsl"),
source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/circle.wgsl").into()),
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("circle.pipeline.layout"),
bind_group_layouts: &[Some(frame_bgl)],
immediate_size: 0,
});
let circle_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("circle.pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[CircleInstance::layout()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: content_stencil.clone(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: sample_count,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
});
let circle_xray_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("circle_xray.pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[CircleInstance::layout()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: content_stencil.clone(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: sample_count,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
});
(circle_pipeline, circle_xray_pipeline)
}
/// Helper to extract an analytical circle or circular arc instance from a `WireModel`.
pub fn extract_circle_instance(
wire: &crate::scene::WireModel,
draw_depth: f32,
) -> Option<CircleInstance> {
// Only thin 2D planar circles or circular arcs have exactly 1 PlanarCircle / Arc tangent geometry
// and no 3D mesh fills or text quads.
if wire.tangent_geoms.len() != 1
|| !wire.fill_tris.is_empty()
|| !wire.pick_tris.is_empty()
|| !wire.text_verts.is_empty()
|| wire.render_instance.is_some()
{
return None;
}
let (center, axis_x, axis_y, radius, start_angle, end_angle) = match wire.tangent_geoms[0] {
crate::scene::model::wire_model::TangentGeom::PlanarCircle {
center,
axis_x,
axis_y,
radius,
} => (center, axis_x, axis_y, radius, 0.0f32, std::f32::consts::TAU),
crate::scene::model::wire_model::TangentGeom::Arc {
center,
axis_x,
axis_y,
radius,
start_angle,
end_angle,
} => (
center,
axis_x,
axis_y,
radius,
start_angle as f32,
end_angle as f32,
),
_ => return None,
};
if radius <= 0.0 || !radius.is_finite() || !start_angle.is_finite() || !end_angle.is_finite() {
return None;
}
let (ch, cl) = split_ds_xyz(center[0], center[1], center[2]);
let hw = wire.line_weight_px * 0.5;
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]];
Some(CircleInstance {
center_high: ch,
_pad0: 0.0,
center_low: cl,
radius: radius as f32,
axis_x: [axis_x[0] as f32, axis_x[1] as f32, axis_x[2] as f32],
start_angle,
axis_y: [axis_y[0] as f32, axis_y[1] as f32, axis_y[2] as f32],
end_angle,
color: wire.color,
params: [hw, wire.pattern_length, draw_depth, 0.0],
pat0,
pat1,
})
}
fn split_ds_xyz(x: f64, y: f64, z: f64) -> ([f32; 3], [f32; 3]) {
let hx = x as f32;
let lx = (x - hx as f64) as f32;
let hy = y as f32;
let ly = (y - hy as f64) as f32;
let hz = z as f32;
let lz = (z - hz as f64) as f32;
([hx, hy, hz], [lx, ly, lz])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn circle_instance_size_matches_stride() {
assert_eq!(std::mem::size_of::<CircleInstance>(), 128);
}
#[test]
fn circle_shader_validates_with_naga() {
let source = include_str!("../../shaders/circle.wgsl");
let module = naga::front::wgsl::parse_str(source).expect("circle.wgsl parses cleanly");
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.expect("circle.wgsl validates cleanly with naga");
}
#[test]
fn extract_circle_instance_planar_success() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarCircle {
center: [100.0, 200.0, 300.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 50.0,
});
wire.line_weight_px = 2.0;
wire.color = [1.0, 0.0, 0.0, 1.0];
let inst = extract_circle_instance(&wire, 1.5).expect("should extract planar circle");
assert_eq!(inst.radius, 50.0);
assert_eq!(inst.start_angle, 0.0);
assert_eq!(inst.end_angle, std::f32::consts::TAU);
assert_eq!(inst.center_high, [100.0, 200.0, 300.0]);
assert_eq!(inst.color, [1.0, 0.0, 0.0, 1.0]);
assert_eq!(inst.params[0], 1.0); // half lineweight
assert_eq!(inst.params[2], 1.5); // draw depth
}
#[test]
fn extract_circle_instance_arc_success() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc {
center: [10.0, 20.0, 30.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 25.0,
start_angle: 0.5,
end_angle: 2.5,
});
wire.line_weight_px = 3.0;
wire.color = [0.0, 1.0, 0.0, 1.0];
let inst = extract_circle_instance(&wire, 0.5).expect("should extract circular arc");
assert_eq!(inst.radius, 25.0);
assert_eq!(inst.start_angle, 0.5);
assert_eq!(inst.end_angle, 2.5);
assert_eq!(inst.center_high, [10.0, 20.0, 30.0]);
assert_eq!(inst.color, [0.0, 1.0, 0.0, 1.0]);
assert_eq!(inst.params[0], 1.5);
}
#[test]
fn extract_circle_instance_rejects_non_planar_or_complex() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarCircle {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 10.0,
});
// 1. With fill_tris (e.g. 3D solid or hatch)
wire.fill_tris = vec![[0.0; 3]];
assert!(extract_circle_instance(&wire, 0.0).is_none());
// 2. Clear fill_tris, add render_instance (block instance)
wire.fill_tris.clear();
wire.render_instance = Some(crate::scene::model::instance_model::RenderInstance {
source_id: 1,
translation: [0.0; 3],
});
assert!(extract_circle_instance(&wire, 0.0).is_none());
// 3. Invalid radius
wire.render_instance = None;
wire.tangent_geoms[0] = crate::scene::model::wire_model::TangentGeom::PlanarCircle {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: -5.0,
};
assert!(extract_circle_instance(&wire, 0.0).is_none());
}
}
+420
View File
@@ -0,0 +1,420 @@
//! GPU-instanced analytical ellipse buffer & pipeline layout.
//!
//! Renders 2D/planar ellipses and elliptical arcs as analytical screen/plane-aligned quads
//! with sub-pixel screen-space derivative anti-aliasing. Each ellipse is a single instance
//! (6 vertices for a 2-triangle quad), eliminating CPU re-tessellation on zoom and providing
//! mathematically exact curvature at any magnification.
use iced::wgpu;
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct EllipseInstance {
pub center_high: [f32; 3],
pub _pad0: f32,
pub center_low: [f32; 3],
pub minor_axis_ratio: f32,
pub major_axis: [f32; 3],
pub start_param: f32,
pub normal: [f32; 3],
pub end_param: f32,
pub color: [f32; 4],
/// `[half_width, pattern_length, draw_depth, padding]`
pub params: [f32; 4],
pub pat0: [f32; 4],
pub pat1: [f32; 4],
}
impl EllipseInstance {
pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
const ATTRS: &[wgpu::VertexAttribute] = &[
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, center_high) as u64,
shader_location: 0,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, center_low) as u64,
shader_location: 1,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, major_axis) as u64,
shader_location: 2,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, normal) as u64,
shader_location: 3,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, color) as u64,
shader_location: 4,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, params) as u64,
shader_location: 5,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, pat0) as u64,
shader_location: 6,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, pat1) as u64,
shader_location: 7,
format: wgpu::VertexFormat::Float32x4,
},
];
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<EllipseInstance>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: ATTRS,
}
}
}
pub struct EllipseGpu {
pub instance_buffer: wgpu::Buffer,
pub instance_count: u32,
}
impl EllipseGpu {
pub fn from_instances(
device: &wgpu::Device,
queue: &wgpu::Queue,
label: &str,
instances: &[EllipseInstance],
) -> Self {
let instance_buffer = super::gpu_upload::upload_buffer(
device,
queue,
label,
instances,
wgpu::BufferUsages::VERTEX,
);
Self {
instance_buffer,
instance_count: instances.len() as u32,
}
}
}
/// Build the base and xray ellipse pipelines.
pub fn create_pipelines(
device: &wgpu::Device,
frame_bgl: &wgpu::BindGroupLayout,
color_format: wgpu::TextureFormat,
sample_count: u32,
content_stencil: &wgpu::StencilState,
) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("ellipse.wgsl"),
source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/ellipse.wgsl").into()),
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("ellipse.pipeline.layout"),
bind_group_layouts: &[Some(frame_bgl)],
immediate_size: 0,
});
let ellipse_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("ellipse.pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[EllipseInstance::layout()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: content_stencil.clone(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: sample_count,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
});
let ellipse_xray_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("ellipse_xray.pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[EllipseInstance::layout()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: content_stencil.clone(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: sample_count,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
});
(ellipse_pipeline, ellipse_xray_pipeline)
}
/// Helper to extract an analytical ellipse instance from a `WireModel`.
pub fn extract_ellipse_instance(
wire: &crate::scene::WireModel,
draw_depth: f32,
) -> Option<EllipseInstance> {
if wire.tangent_geoms.len() != 1
|| !wire.fill_tris.is_empty()
|| !wire.pick_tris.is_empty()
|| !wire.text_verts.is_empty()
|| wire.render_instance.is_some()
{
return None;
}
let crate::scene::model::wire_model::TangentGeom::PlanarEllipse {
center,
major_axis,
normal,
minor_axis_ratio,
start_param,
end_param,
} = wire.tangent_geoms[0] else {
return None;
};
let major_len_sq = major_axis[0] * major_axis[0] + major_axis[1] * major_axis[1] + major_axis[2] * major_axis[2];
let norm_len_sq = normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2];
if major_len_sq <= 1e-12 || !major_len_sq.is_finite()
|| norm_len_sq <= 1e-12 || !norm_len_sq.is_finite()
|| minor_axis_ratio <= 1e-6 || !minor_axis_ratio.is_finite()
|| !start_param.is_finite() || !end_param.is_finite()
{
return None;
}
let (ch, cl) = split_ds_xyz(center[0], center[1], center[2]);
let hw = wire.line_weight_px * 0.5;
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]];
Some(EllipseInstance {
center_high: ch,
_pad0: 0.0,
center_low: cl,
minor_axis_ratio: minor_axis_ratio.min(1.0) as f32,
major_axis: [major_axis[0] as f32, major_axis[1] as f32, major_axis[2] as f32],
start_param: start_param as f32,
normal: [normal[0] as f32, normal[1] as f32, normal[2] as f32],
end_param: end_param as f32,
color: wire.color,
params: [hw, wire.pattern_length, draw_depth, 0.0],
pat0,
pat1,
})
}
fn split_ds_xyz(x: f64, y: f64, z: f64) -> ([f32; 3], [f32; 3]) {
let hx = x as f32;
let lx = (x - hx as f64) as f32;
let hy = y as f32;
let ly = (y - hy as f64) as f32;
let hz = z as f32;
let lz = (z - hz as f64) as f32;
([hx, hy, hz], [lx, ly, lz])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ellipse_instance_size_matches_stride() {
assert_eq!(std::mem::size_of::<EllipseInstance>(), 128);
}
#[test]
fn ellipse_shader_validates_with_naga() {
let source = include_str!("../../shaders/ellipse.wgsl");
let module = naga::front::wgsl::parse_str(source).expect("ellipse.wgsl parses cleanly");
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.expect("ellipse.wgsl validates cleanly with naga");
}
#[test]
fn extract_ellipse_instance_success() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarEllipse {
center: [100.0, 200.0, 300.0],
major_axis: [50.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.5,
start_param: 0.0,
end_param: std::f64::consts::TAU,
});
wire.line_weight_px = 2.0;
wire.color = [1.0, 0.5, 0.0, 1.0];
let inst = extract_ellipse_instance(&wire, 1.0).expect("should extract ellipse");
assert_eq!(inst.center_high, [100.0, 200.0, 300.0]);
assert_eq!(inst.major_axis, [50.0, 0.0, 0.0]);
assert_eq!(inst.minor_axis_ratio, 0.5);
assert_eq!(inst.start_param, 0.0);
assert_eq!(inst.end_param, std::f32::consts::TAU);
assert_eq!(inst.color, [1.0, 0.5, 0.0, 1.0]);
assert_eq!(inst.params[0], 1.0);
assert_eq!(inst.params[2], 1.0);
}
#[test]
fn extract_elliptical_arc_instance_success() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarEllipse {
center: [0.0, 0.0, 0.0],
major_axis: [10.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.8,
start_param: 0.5,
end_param: 2.5,
});
wire.line_weight_px = 1.0;
wire.color = [0.0, 1.0, 0.0, 1.0];
let inst = extract_ellipse_instance(&wire, 0.5).expect("should extract elliptical arc");
assert_eq!(inst.minor_axis_ratio, 0.8);
assert_eq!(inst.start_param, 0.5);
assert_eq!(inst.end_param, 2.5);
assert_eq!(inst.params[0], 0.5); // half-width
assert_eq!(inst.params[2], 0.5); // draw-depth
}
fn rust_closest_point_ellipse(p_in: [f32; 2], ab_in: [f32; 2]) -> [f32; 2] {
let px = p_in[0].abs();
let py = p_in[1].abs();
let a = ab_in[0];
let b = ab_in[1];
if (a - b).abs() < 1e-6 {
let len = (px * px + py * py).sqrt();
let q = if len > 1e-6 {
[px / len * a, py / len * a]
} else {
[a, 0.0]
};
return [
if p_in[0] >= 0.0 { q[0] } else { -q[0] },
if p_in[1] >= 0.0 { q[1] } else { -q[1] },
];
}
// Initial estimate from eccentric anomaly of radial direction
let mut t = (py * a).atan2(px * b);
let k = a * a - b * b;
// 3 iterations of Newton-Raphson
for _ in 0..3 {
let (st, ct) = t.sin_cos();
let f = k * st * ct - px * a * st + py * b * ct;
let f_prime = k * (ct * ct - st * st) - px * a * ct - py * b * st;
if f_prime.abs() > 1e-7 {
t -= f / f_prime;
}
}
t = t.clamp(0.0, std::f32::consts::FRAC_PI_2);
let q = [a * t.cos(), b * t.sin()];
[
if p_in[0] >= 0.0 { q[0] } else { -q[0] },
if p_in[1] >= 0.0 { q[1] } else { -q[1] },
]
}
#[test]
fn closest_point_ellipse_accuracy() {
let a = 100.0f32;
let b = 40.0f32;
// Test points directly on the ellipse: closest point should be itself!
for i in 0..36 {
let angle = (i as f32) * std::f32::consts::TAU / 36.0;
let px = a * angle.cos();
let py = b * angle.sin();
let q = rust_closest_point_ellipse([px, py], [a, b]);
let dist = ((q[0] - px).powi(2) + (q[1] - py).powi(2)).sqrt();
assert!(
dist < 0.2,
"Point on ellipse at angle {angle} failed: px={px}, py={py}, q={:?}, dist={dist}",
q
);
// Verify q is on the ellipse (x/a)^2 + (y/b)^2 == 1
let eq = (q[0] / a).powi(2) + (q[1] / b).powi(2);
assert!((eq - 1.0).abs() < 0.01, "eq={eq} for angle {angle}");
}
// Test points offset from ellipse (inside and outside)
for i in 0..36 {
let angle = (i as f32) * std::f32::consts::TAU / 36.0;
for offset in [-5.0f32, -1.0, 1.0, 5.0] {
let norm = [angle.cos() / a, angle.sin() / b];
let norm_len = (norm[0] * norm[0] + norm[1] * norm[1]).sqrt();
let un = [norm[0] / norm_len, norm[1] / norm_len];
let px = a * angle.cos() + offset * un[0];
let py = b * angle.sin() + offset * un[1];
let q = rust_closest_point_ellipse([px, py], [a, b]);
let eq = (q[0] / a).powi(2) + (q[1] / b).powi(2);
assert!(
(eq - 1.0).abs() < 0.05,
"Offset point at angle {angle}, offset {offset}: q={:?}, eq={eq}",
q
);
let measured_dist = ((q[0] - px).powi(2) + (q[1] - py).powi(2)).sqrt();
assert!(
(measured_dist - offset.abs()).abs() < 0.2,
"Distance error at angle {angle}, offset {offset}: measured={measured_dist}"
);
}
}
}
}
+231 -2
View File
@@ -1,6 +1,8 @@
#[cfg(test)]
mod gpu_tests;
mod device_capabilities;
pub mod circle_gpu;
pub mod ellipse_gpu;
pub mod face3d_gpu;
pub mod gpu_budget;
pub mod gpu_upload;
@@ -20,6 +22,8 @@ pub mod wire_gpu;
use iced::wgpu;
use iced::{Rectangle, Size};
pub use circle_gpu::{CircleGpu, CircleInstance};
pub use ellipse_gpu::{EllipseGpu, EllipseInstance};
pub use face3d_gpu::Face3DGpu;
pub use wipeout_gpu::WipeoutGpu;
pub use image_gpu::ImageGpu;
@@ -79,6 +83,8 @@ pub struct Pipeline {
shadow_pipeline: wgpu::RenderPipeline,
shadow_plain_pipeline: wgpu::RenderPipeline,
wire_pipeline: wgpu::RenderPipeline,
circle_pipeline: wgpu::RenderPipeline,
ellipse_pipeline: wgpu::RenderPipeline,
block_wire_pipeline: wgpu::RenderPipeline,
/// Stamps clip-boundary polygons into the stencil buffer (viewports + XCLIP).
clip_mask_pipeline: wgpu::RenderPipeline,
@@ -89,6 +95,8 @@ pub struct Pipeline {
/// Same shader as wire_pipeline but depth_compare=Greater, depth_write_enabled=false.
/// Used to draw ghost copies of selected wires through occluding geometry.
wire_xray_pipeline: wgpu::RenderPipeline,
circle_xray_pipeline: wgpu::RenderPipeline,
ellipse_xray_pipeline: wgpu::RenderPipeline,
block_wire_xray_pipeline: wgpu::RenderPipeline,
/// Layout for the per-wire `WireConst` storage buffer (group 1 of the wire /
/// xray pipelines). `Some` on any storage-capable device; `None` in packed
@@ -225,6 +233,8 @@ pub struct Pipeline {
/// replace this thin draw-range list on camera changes without touching the
/// shared resident buffer.
pub(crate) gpu_wires: std::sync::Arc<Vec<WireGpu>>,
pub(crate) gpu_circles: std::sync::Arc<Vec<CircleGpu>>,
pub(crate) gpu_ellipses: std::sync::Arc<Vec<EllipseGpu>>,
pub(crate) gpu_block_wires: std::sync::Arc<Vec<BlockWireGpu>>,
/// Persistent per-entity wire instance arena (capability-selected format).
/// When active, `gpu_wires` is a thin wrapper over this arena's buffers and an
@@ -256,6 +266,8 @@ pub struct Pipeline {
clip_boundary: Option<(wgpu::Buffer, u32)>,
/// Ghost copies (25% alpha) of selected wires for the X-ray depth pass.
gpu_selected_wires: Vec<WireGpu>,
gpu_selected_circles: Vec<CircleGpu>,
gpu_selected_ellipses: Vec<EllipseGpu>,
gpu_selected_block_wires: Vec<BlockWireGpu>,
/// Command-preview / interim / grip-drag overlay wires. Re-uploaded every
/// frame they are present (small), drawn on top of the base wire pass — so
@@ -2083,6 +2095,22 @@ impl Pipeline {
&content_stencil,
);
let (circle_pipeline, circle_xray_pipeline) = circle_gpu::create_pipelines(
device,
&frame_bgl,
format,
MSAA_SAMPLES,
&content_stencil,
);
let (ellipse_pipeline, ellipse_xray_pipeline) = ellipse_gpu::create_pipelines(
device,
&frame_bgl,
format,
MSAA_SAMPLES,
&content_stencil,
);
let viewcube = ViewCubePipeline::new(device, queue, format);
let init_size = Size::new(1, 1);
@@ -2223,6 +2251,10 @@ impl Pipeline {
wire_black_pipeline,
block_wire_black_pipeline,
wire_xray_pipeline,
circle_pipeline,
circle_xray_pipeline,
ellipse_pipeline,
ellipse_xray_pipeline,
block_wire_xray_pipeline,
wire_const_bgl,
block_wire_const_bgl,
@@ -2295,6 +2327,8 @@ impl Pipeline {
blit_uniform_buffer,
surface_format: format,
gpu_wires: std::sync::Arc::new(vec![]),
gpu_circles: std::sync::Arc::new(vec![]),
gpu_ellipses: std::sync::Arc::new(vec![]),
gpu_block_wires: std::sync::Arc::new(vec![]),
wire_arena: None,
wire_arena_mesh: None,
@@ -2308,6 +2342,8 @@ impl Pipeline {
silhouette_key: (usize::MAX, u64::MAX, [u32::MAX; 3], false),
clip_boundary: None,
gpu_selected_wires: vec![],
gpu_selected_circles: vec![],
gpu_selected_ellipses: vec![],
gpu_selected_block_wires: vec![],
gpu_preview_wires: vec![],
gpu_wipeouts: vec![],
@@ -2371,6 +2407,66 @@ impl Pipeline {
)
}
/// Upload GPU-instanced analytical circles.
pub fn upload_circles(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
wires: &[WireModel],
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
) -> Vec<CircleGpu> {
let mut instances: Vec<CircleInstance> = Vec::new();
for wire in wires {
if !wire.display_visible {
continue;
}
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(inst) = circle_gpu::extract_circle_instance(wire, depth) {
instances.push(inst);
}
}
if instances.is_empty() {
vec![]
} else {
vec![CircleGpu::from_instances(
device,
queue,
"viewer.circles",
&instances,
)]
}
}
/// Upload GPU-instanced analytical ellipses.
pub fn upload_ellipses(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
wires: &[WireModel],
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
) -> Vec<EllipseGpu> {
let mut instances: Vec<EllipseInstance> = Vec::new();
for wire in wires {
if !wire.display_visible {
continue;
}
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(inst) = ellipse_gpu::extract_ellipse_instance(wire, depth) {
instances.push(inst);
}
}
if instances.is_empty() {
vec![]
} else {
vec![EllipseGpu::from_instances(
device,
queue,
"viewer.ellipses",
&instances,
)]
}
}
/// Build resident batches and a handle index shared across viewport slots.
pub fn build_wire_buffers(
&mut self,
@@ -2383,6 +2479,8 @@ impl Pipeline {
std::sync::Arc<Vec<WireGpu>>,
std::sync::Arc<Vec<BlockWireGpu>>,
std::sync::Arc<rustc_hash::FxHashMap<u64, Vec<u32>>>,
std::sync::Arc<Vec<CircleGpu>>,
std::sync::Arc<Vec<EllipseGpu>>,
) {
// Batch the wire pass: instead of one GPU buffer + one draw call per
// WireModel (tens of thousands on a large drawing), merge maximal runs
@@ -2407,11 +2505,19 @@ impl Pipeline {
i += 1;
continue;
}
if circle_gpu::extract_circle_instance(&wires[i], 0.0).is_some()
|| ellipse_gpu::extract_ellipse_instance(&wires[i], 0.0).is_some()
{
i += 1;
continue;
}
let mesh_edge = is_mesh_edge(&wires[i]);
let mut j = i + 1;
while j < wires.len()
&& wires[j].display_visible
&& wires[j].render_instance.is_none()
&& circle_gpu::extract_circle_instance(&wires[j], 0.0).is_none()
&& ellipse_gpu::extract_ellipse_instance(&wires[j], 0.0).is_none()
&& is_mesh_edge(&wires[j]) == mesh_edge
{
j += 1;
@@ -2462,10 +2568,14 @@ impl Pipeline {
wires.len(),
);
}
let circle_batches = self.upload_circles(device, queue, wires, depth_map);
let ellipse_batches = self.upload_ellipses(device, queue, wires, depth_map);
(
std::sync::Arc::new(batches),
std::sync::Arc::new(block_batches),
std::sync::Arc::new(index),
std::sync::Arc::new(circle_batches),
std::sync::Arc::new(ellipse_batches),
)
}
@@ -2492,6 +2602,8 @@ impl Pipeline {
if selected.is_empty() && hovered.is_empty() && annotation_context_wires.is_empty() {
self.gpu_selected_wires = vec![];
self.gpu_selected_block_wires = vec![];
self.gpu_selected_circles = vec![];
self.gpu_selected_ellipses = vec![];
return;
}
// Gather borrowed highlighted wires via the prebuilt index —
@@ -2527,15 +2639,79 @@ impl Pipeline {
hover_wires.push(wire);
}
}
let mut selected_circles: Vec<CircleInstance> = Vec::new();
for &wire in &selected_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(mut inst) = circle_gpu::extract_circle_instance(wire, depth) {
if let Some(tint) = selected_tint {
inst.color = tint;
}
selected_circles.push(inst);
}
}
for &wire in &hover_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(mut inst) = circle_gpu::extract_circle_instance(wire, depth) {
inst.color = WireModel::HOVER;
selected_circles.push(inst);
}
}
self.gpu_selected_circles = if selected_circles.is_empty() {
vec![]
} else {
vec![CircleGpu::from_instances(
device,
queue,
"viewer.selected_circles",
&selected_circles,
)]
};
let mut selected_ellipses: Vec<EllipseInstance> = Vec::new();
for &wire in &selected_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(mut inst) = ellipse_gpu::extract_ellipse_instance(wire, depth) {
if let Some(tint) = selected_tint {
inst.color = tint;
}
selected_ellipses.push(inst);
}
}
for &wire in &hover_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(mut inst) = ellipse_gpu::extract_ellipse_instance(wire, depth) {
inst.color = WireModel::HOVER;
selected_ellipses.push(inst);
}
}
self.gpu_selected_ellipses = if selected_ellipses.is_empty() {
vec![]
} else {
vec![EllipseGpu::from_instances(
device,
queue,
"viewer.selected_ellipses",
&selected_ellipses,
)]
};
let selected_regular: Vec<&WireModel> = selected_wires
.iter()
.copied()
.filter(|wire| wire.render_instance.is_none())
.filter(|wire| {
wire.render_instance.is_none()
&& circle_gpu::extract_circle_instance(wire, 0.0).is_none()
&& ellipse_gpu::extract_ellipse_instance(wire, 0.0).is_none()
})
.collect();
let hover_regular: Vec<&WireModel> = hover_wires
.iter()
.copied()
.filter(|wire| wire.render_instance.is_none())
.filter(|wire| {
wire.render_instance.is_none()
&& circle_gpu::extract_circle_instance(wire, 0.0).is_none()
&& ellipse_gpu::extract_ellipse_instance(wire, 0.0).is_none()
})
.collect();
let selected_blocks: Vec<&WireModel> = selected_wires
.iter()
@@ -4373,6 +4549,26 @@ impl Pipeline {
}
bind_and_draw_block_wire(&mut pass, wire);
}
// Analytical GPU circles (instanced screen-space quads)
if self.gpu_circles.iter().any(|cg| cg.instance_count > 0) {
pass.set_pipeline(&self.circle_pipeline);
for cg in self.gpu_circles.iter() {
if cg.instance_count > 0 {
pass.set_vertex_buffer(0, cg.instance_buffer.slice(..));
pass.draw(0..6, 0..cg.instance_count);
}
}
}
// Analytical GPU ellipses (instanced screen-space quads)
if self.gpu_ellipses.iter().any(|eg| eg.instance_count > 0) {
pass.set_pipeline(&self.ellipse_pipeline);
for eg in self.gpu_ellipses.iter() {
if eg.instance_count > 0 {
pass.set_vertex_buffer(0, eg.instance_buffer.slice(..));
pass.draw(0..6, 0..eg.instance_count);
}
}
}
// Live overlay wires (command preview / interim / grip drag) always
// on top: the xray pipeline (depth_compare=Always, no depth write)
// keeps them visible through any occluding geometry — a 3D solid, or
@@ -4563,6 +4759,32 @@ impl Pipeline {
bind_and_draw_block_wire(&mut pass, wire);
}
}
if !self.gpu_selected_circles.is_empty() {
pass.set_pipeline(if hidden_line {
&self.circle_pipeline
} else {
&self.circle_xray_pipeline
});
for cg in &self.gpu_selected_circles {
if cg.instance_count > 0 {
pass.set_vertex_buffer(0, cg.instance_buffer.slice(..));
pass.draw(0..6, 0..cg.instance_count);
}
}
}
if !self.gpu_selected_ellipses.is_empty() {
pass.set_pipeline(if hidden_line {
&self.ellipse_pipeline
} else {
&self.ellipse_xray_pipeline
});
for eg in &self.gpu_selected_ellipses {
if eg.instance_count > 0 {
pass.set_vertex_buffer(0, eg.instance_buffer.slice(..));
pass.draw(0..6, 0..eg.instance_count);
}
}
}
if let Some(atlas) = &self.text_atlas_gpu {
if !self.text_highlight_gpu.is_empty() {
pass.set_pipeline(&self.text_highlight_pipeline);
@@ -4711,6 +4933,8 @@ impl Pipeline {
self.gpu_wires = std::sync::Arc::new(Vec::new());
self.gpu_block_wires = std::sync::Arc::new(Vec::new());
self.gpu_circles = std::sync::Arc::new(Vec::new());
self.gpu_ellipses = std::sync::Arc::new(Vec::new());
self.wire_handle_index = std::sync::Arc::new(rustc_hash::FxHashMap::default());
self.wire_arena = None;
self.wire_arena_mesh = None;
@@ -4721,6 +4945,8 @@ impl Pipeline {
self.gpu_selected_wires.clear();
self.gpu_selected_block_wires.clear();
self.gpu_selected_circles.clear();
self.gpu_selected_ellipses.clear();
self.gpu_preview_wires.clear();
self.hatch_gpu.clear();
self.gpu_wipeouts.clear();
@@ -4990,6 +5216,7 @@ impl MultiPipeline {
self.wire_buffer_cache.retain(|_, entry| {
std::sync::Arc::strong_count(&entry.0) > 1
|| std::sync::Arc::strong_count(&entry.1) > 1
|| std::sync::Arc::strong_count(&entry.3) > 1
});
self.block_geometry.retain(|_, chunks| {
chunks.iter().any(|chunk| std::sync::Arc::strong_count(&chunk.bind_group) > 1)
@@ -5140,6 +5367,8 @@ pub struct MultiPipeline {
std::sync::Arc<Vec<WireGpu>>,
std::sync::Arc<Vec<BlockWireGpu>>,
std::sync::Arc<rustc_hash::FxHashMap<u64, Vec<u32>>>,
std::sync::Arc<Vec<CircleGpu>>,
std::sync::Arc<Vec<EllipseGpu>>,
),
>,
}
+5 -1
View File
@@ -126,7 +126,11 @@ pub fn split_wires(wires: &[WireModel]) -> (Vec<&WireModel>, Vec<&WireModel>) {
wires
.iter()
.filter(|wire| {
wire.display_visible && !wire.points.is_empty() && wire.render_instance.is_none()
wire.display_visible
&& !wire.points.is_empty()
&& wire.render_instance.is_none()
&& super::circle_gpu::extract_circle_instance(wire, 0.0).is_none()
&& super::ellipse_gpu::extract_ellipse_instance(wire, 0.0).is_none()
})
.partition(|wire| !wire.fill_is_3d)
}
+23 -1
View File
@@ -853,6 +853,22 @@ impl shader::Primitive for Primitive {
&mut pipeline.block_geometry,
),
);
inner.gpu_circles = std::sync::Arc::new(
inner.upload_circles(
device,
queue,
&vp_wires,
&draw_depths,
),
);
inner.gpu_ellipses = std::sync::Arc::new(
inner.upload_ellipses(
device,
queue,
&vp_wires,
&draw_depths,
),
);
if _patched {
wire_arena::patch_handle_index(
&mut inner.wire_handle_index,
@@ -911,10 +927,14 @@ impl shader::Primitive for Primitive {
// Other panes retain their own references to shared geometry.
inner.gpu_wires = std::sync::Arc::new(Vec::new());
inner.gpu_block_wires = std::sync::Arc::new(Vec::new());
inner.gpu_circles = std::sync::Arc::new(Vec::new());
inner.gpu_ellipses = std::sync::Arc::new(Vec::new());
let held_before = pipeline.wire_buffer_cache.len();
pipeline.wire_buffer_cache.retain(|_, (w, b, _)| {
pipeline.wire_buffer_cache.retain(|_, (w, b, _, c, e)| {
std::sync::Arc::strong_count(w) > 1
|| std::sync::Arc::strong_count(b) > 1
|| std::sync::Arc::strong_count(c) > 1
|| std::sync::Arc::strong_count(e) > 1
});
if _perf {
crate::perf_record!(
@@ -959,6 +979,8 @@ impl shader::Primitive for Primitive {
inner.gpu_wires = built.0;
inner.gpu_block_wires = built.1;
inner.wire_handle_index = built.2;
inner.gpu_circles = built.3;
inner.gpu_ellipses = built.4;
} // end !arena_served
inner.cached_wire_id = vp.wire_content_id;
if _perf {
+224
View File
@@ -0,0 +1,224 @@
// GPU-instanced analytical circle shader — renders 2D/planar circles as screen/plane-aligned quads.
// Topology: TriangleList, 6 vertices drawn per INSTANCE.
//
// One instance = one complete circle. The six vertex IDs map to the corners of a
// two-triangle quad covering the circle's bounding box (+ lineweight margin).
//
// The fragment shader analytically computes the exact distance to the circle perimeter:
// d_world = abs(length(local_xy) - radius)
// and uses screen-space derivatives (fwidth) to achieve perfect sub-pixel anti-aliasing
// at any zoom level, without any polygon faceting or CPU re-tessellation.
struct Uniforms {
viewport_size: vec2<f32>,
world_per_pixel: f32,
lwdisplay_enable: f32,
flat_shade: f32,
transparency_enable: f32,
linetype_scale: f32,
lineweight_scale: f32,
// Relative-to-eye (double-single)
view_rot: mat4x4<f32>,
eye_high: vec3<f32>,
_pad_eh: f32,
eye_low: vec3<f32>,
_pad_el: f32,
}
@group(0) @binding(0) var<uniform> u: Uniforms;
const DRAW_ORDER_BIAS: f32 = 0.001;
const MODEL_LINEWEIGHT_BOOST: f32 = 2.0;
const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0;
const TAU: f32 = 6.283185307179586;
struct InstanceIn {
@location(0) center_high: vec4<f32>, // xyz = center_high, w = unused
@location(1) center_low: vec4<f32>, // xyz = center_low, w = radius
@location(2) axis_x: vec4<f32>, // xyz = axis_x, w = start_angle
@location(3) axis_y: vec4<f32>, // xyz = axis_y, w = end_angle
@location(4) color: vec4<f32>, // rgba
@location(5) params: vec4<f32>, // half_width_px, pattern_length, draw_depth, unused
@location(6) pat0: vec4<f32>,
@location(7) pat1: vec4<f32>,
}
struct VertexOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) local_pos: vec2<f32>,
@location(1) radius: f32,
@location(2) hw_px: f32,
@location(3) color: vec4<f32>,
@location(4) pattern_length: f32,
@location(5) pat0: vec4<f32>,
@location(6) pat1: vec4<f32>,
@location(7) @interpolate(flat) min_elem: f32,
@location(8) @interpolate(flat) start_angle: f32,
@location(9) @interpolate(flat) end_angle: f32,
}
fn resolve_hw(px_hw: f32) -> f32 {
var display_hw = max(px_hw * u.lineweight_scale, 0.5);
if u.lineweight_scale < 0.0 {
let scale = -u.lineweight_scale;
let base_hw = select(
min(px_hw * MODEL_LINEWEIGHT_BOOST, MODEL_LINEWEIGHT_MAX_PX * 0.5),
0.5,
px_hw <= 0.5,
);
display_hw = max(base_hw * scale, 0.5);
}
return select(0.5, display_hw, u.lwdisplay_enable > 0.5);
}
@vertex fn vs_main(@builtin(vertex_index) vid: u32, in: InstanceIn) -> VertexOut {
// Two-triangle unit quad corner table:
// vid 0,1,2 = (-1,-1) ( 1,-1) ( 1, 1)
// vid 3,4,5 = (-1,-1) ( 1, 1) (-1, 1)
let u_arr = array<f32, 6>(-1.0, 1.0, 1.0, -1.0, 1.0, -1.0);
let v_arr = array<f32, 6>(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
let u_val = u_arr[vid];
let v_val = v_arr[vid];
let center_high = in.center_high.xyz;
let center_low = in.center_low.xyz;
let radius = in.center_low.w;
let axis_x = in.axis_x.xyz;
let axis_y = in.axis_y.xyz;
let color = in.color;
let hw_px = resolve_hw(in.params.x);
let pattern_length = in.params.y;
let draw_depth = in.params.z;
// Expand bounding quad so anti-aliasing margin and lineweight never clip
let margin_world = (hw_px + 2.0) * max(u.world_per_pixel, 1e-6);
let ext = radius + margin_world;
let center_rel = (center_high - u.eye_high) + (center_low - u.eye_low);
let world_pos_rel = center_rel + (u_val * axis_x + v_val * axis_y) * ext;
var clip_pos = u.view_rot * vec4<f32>(world_pos_rel, 1.0);
clip_pos.z = clip_pos.z - draw_depth * DRAW_ORDER_BIAS * clip_pos.w;
var out: VertexOut;
out.clip_pos = clip_pos;
out.local_pos = vec2<f32>(u_val * ext, v_val * ext);
out.radius = radius;
out.hw_px = hw_px;
out.color = color;
out.pattern_length = pattern_length;
out.pat0 = in.pat0;
out.pat1 = in.pat1;
// Compute smallest non-zero pattern element for LOD short-circuiting
let elems = array<f32, 8>(in.pat0.x, in.pat0.y, in.pat0.z, in.pat0.w, in.pat1.x, in.pat1.y, in.pat1.z, in.pat1.w);
var min_elem = 1e9;
for (var i = 0u; i < 8u; i++) {
let e = abs(elems[i]);
if e > 0.0 && e < min_elem {
min_elem = e;
}
}
out.min_elem = select(0.0, min_elem, min_elem < 1e8);
out.start_angle = in.axis_x.w;
out.end_angle = in.axis_y.w;
return out;
}
fn mod_tau(val: f32) -> f32 {
let m = val % TAU;
return select(m, m + TAU, m < 0.0);
}
fn in_dash(dist: f32, pat_len: f32, p0: vec4<f32>, p1: vec4<f32>) -> bool {
let elems = array<f32, 8>(p0.x, p0.y, p0.z, p0.w, p1.x, p1.y, p1.z, p1.w);
var count = 0u;
for (var i = 0u; i < 8u; i++) {
if elems[i] != 0.0 { count = i + 1u; }
}
if count == 0u {
return true;
}
let d = ((dist % pat_len) + pat_len) % pat_len;
var pos = 0.0f;
let dot_half = u.world_per_pixel * 0.75;
for (var i = 0u; i < count; i++) {
let elem = elems[i];
if elem == 0.0 {
let dd = abs(d - pos);
if min(dd, pat_len - dd) <= dot_half { return true; }
} else if elem > 0.0 {
if d >= pos && d < pos + elem { return true; }
pos += elem;
} else {
pos += -elem;
}
}
return false;
}
@fragment fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
let r = length(in.local_pos);
// Screen-space derivative anti-aliasing:
// Use the gradient of the monotonic radial distance `r` instead of `abs(r - radius)`.
// Taking derivatives across the cusp of abs() cancels out at scanline crossings,
// which previously collapsed fw to near-zero and punched periodic 1-2px holes (false dashes).
let grad = vec2<f32>(dpdx(r), dpdy(r));
let grad_len = length(grad);
let fw = max(select(u.world_per_pixel, grad_len, grad_len > 1e-6), 1e-6);
let sa = in.start_angle;
let ea = in.end_angle;
var sweep = mod_tau(ea - sa);
if sweep <= 1e-5 && abs(ea - sa) > 1e-5 {
sweep = TAU;
}
let angle = atan2(in.local_pos.y, in.local_pos.x);
let theta = select(angle, angle + TAU, angle < 0.0);
let d_theta = mod_tau(theta - sa);
var d_world: f32;
var arc_dist: f32;
if sweep >= TAU - 1e-5 || d_theta <= sweep {
// Inside arc sweep or full circle
d_world = abs(r - in.radius);
arc_dist = in.radius * d_theta;
} else {
// Outside sweep: measure distance to start and end endpoints for round end caps
let p_start = in.radius * vec2<f32>(cos(sa), sin(sa));
let p_end = in.radius * vec2<f32>(cos(ea), sin(ea));
let d_start = length(in.local_pos - p_start);
let d_end = length(in.local_pos - p_end);
if d_start < d_end {
d_world = d_start;
arc_dist = 0.0;
} else {
d_world = d_end;
arc_dist = in.radius * sweep;
}
}
let d_px = d_world / fw;
let alpha_cov = clamp(0.5 + in.hw_px - d_px, 0.0, 1.0);
if alpha_cov <= 0.0 {
discard;
}
// Linetype dash pattern test
if in.pattern_length > 0.0 {
let pat_len = in.pattern_length * u.linetype_scale;
if in.min_elem * u.linetype_scale >= u.world_per_pixel && pat_len > 0.0 {
if !in_dash(arc_dist, pat_len, in.pat0, in.pat1) {
discard;
}
}
}
let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5) * alpha_cov;
return vec4<f32>(in.color.rgb, alpha);
}
+275
View File
@@ -0,0 +1,275 @@
// GPU-instanced analytical ellipse shader — renders 2D/planar ellipses and elliptical arcs as quads.
// Topology: TriangleList, 6 vertices drawn per INSTANCE.
//
// Evaluates Inigo Quilez's exact 2D Ellipse Signed Distance Function (quartic closest-point solver)
// with sub-pixel anti-aliasing and parametric arc endpoint clamping for round end caps.
struct Uniforms {
viewport_size: vec2<f32>,
world_per_pixel: f32,
lwdisplay_enable: f32,
flat_shade: f32,
transparency_enable: f32,
linetype_scale: f32,
lineweight_scale: f32,
// Relative-to-eye (double-single)
view_rot: mat4x4<f32>,
eye_high: vec3<f32>,
_pad_eh: f32,
eye_low: vec3<f32>,
_pad_el: f32,
}
@group(0) @binding(0) var<uniform> u: Uniforms;
const DRAW_ORDER_BIAS: f32 = 0.001;
const MODEL_LINEWEIGHT_BOOST: f32 = 2.0;
const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0;
const TAU: f32 = 6.283185307179586;
struct InstanceIn {
@location(0) center_high: vec4<f32>, // xyz = center_high, w = unused
@location(1) center_low: vec4<f32>, // xyz = center_low, w = minor_axis_ratio
@location(2) major_axis: vec4<f32>, // xyz = major_axis (semi-major vector), w = start_param
@location(3) normal: vec4<f32>, // xyz = normal, w = end_param
@location(4) color: vec4<f32>, // rgba
@location(5) params: vec4<f32>, // half_width_px, pattern_length, draw_depth, unused
@location(6) pat0: vec4<f32>,
@location(7) pat1: vec4<f32>,
}
struct VertexOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) local_pos: vec2<f32>,
@location(1) semi_axes: vec2<f32>,
@location(2) hw_px: f32,
@location(3) color: vec4<f32>,
@location(4) pattern_length: f32,
@location(5) pat0: vec4<f32>,
@location(6) pat1: vec4<f32>,
@location(7) @interpolate(flat) min_elem: f32,
@location(8) @interpolate(flat) start_param: f32,
@location(9) @interpolate(flat) end_param: f32,
}
fn resolve_hw(px_hw: f32) -> f32 {
var display_hw = max(px_hw * u.lineweight_scale, 0.5);
if u.lineweight_scale < 0.0 {
let scale = -u.lineweight_scale;
let base_hw = select(
min(px_hw * MODEL_LINEWEIGHT_BOOST, MODEL_LINEWEIGHT_MAX_PX * 0.5),
0.5,
px_hw <= 0.5,
);
display_hw = max(base_hw * scale, 0.5);
}
return select(0.5, display_hw, u.lwdisplay_enable > 0.5);
}
@vertex fn vs_main(@builtin(vertex_index) vid: u32, in: InstanceIn) -> VertexOut {
let u_arr = array<f32, 6>(-1.0, 1.0, 1.0, -1.0, 1.0, -1.0);
let v_arr = array<f32, 6>(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
let u_val = u_arr[vid];
let v_val = v_arr[vid];
let center_high = in.center_high.xyz;
let center_low = in.center_low.xyz;
let major_vec = in.major_axis.xyz;
let norm_vec = in.normal.xyz;
let a = length(major_vec);
let u_axis = select(vec3<f32>(1.0, 0.0, 0.0), major_vec / a, a > 1e-6);
let n_len = length(norm_vec);
let n_axis = select(vec3<f32>(0.0, 0.0, 1.0), norm_vec / n_len, n_len > 1e-6);
let v_axis = normalize(cross(n_axis, u_axis));
let ratio = clamp(in.center_low.w, 1e-6, 1.0);
let b = a * ratio;
let color = in.color;
let hw_px = resolve_hw(in.params.x);
let pattern_length = in.params.y;
let draw_depth = in.params.z;
let margin_world = (hw_px + 2.0) * max(u.world_per_pixel, 1e-6);
let ext_u = a + margin_world;
let ext_v = b + margin_world;
let center_rel = (center_high - u.eye_high) + (center_low - u.eye_low);
let world_pos_rel = center_rel + (u_val * ext_u) * u_axis + (v_val * ext_v) * v_axis;
var clip_pos = u.view_rot * vec4<f32>(world_pos_rel, 1.0);
clip_pos.z = clip_pos.z - draw_depth * DRAW_ORDER_BIAS * clip_pos.w;
var out: VertexOut;
out.clip_pos = clip_pos;
out.local_pos = vec2<f32>(u_val * ext_u, v_val * ext_v);
out.semi_axes = vec2<f32>(a, b);
out.hw_px = hw_px;
out.color = color;
out.pattern_length = pattern_length;
out.pat0 = in.pat0;
out.pat1 = in.pat1;
out.start_param = in.major_axis.w;
out.end_param = in.normal.w;
let elems = array<f32, 8>(in.pat0.x, in.pat0.y, in.pat0.z, in.pat0.w, in.pat1.x, in.pat1.y, in.pat1.z, in.pat1.w);
var min_elem = 1e9;
for (var i = 0u; i < 8u; i++) {
let e = abs(elems[i]);
if e > 0.0 && e < min_elem {
min_elem = e;
}
}
out.min_elem = select(0.0, min_elem, min_elem < 1e8);
return out;
}
fn mod_tau(val: f32) -> f32 {
let m = val % TAU;
return select(m, m + TAU, m < 0.0);
}
struct ClosestResult {
q: vec2<f32>,
t: f32,
}
fn closest_point_ellipse(p_in: vec2<f32>, ab: vec2<f32>) -> ClosestResult {
let px = abs(p_in.x);
let py = abs(p_in.y);
let a = ab.x;
let b = ab.y;
if abs(a - b) < 1e-5 {
let len = length(p_in);
let q_circ = select(p_in, normalize(p_in) * a, len > 1e-6);
let ang = atan2(q_circ.y, q_circ.x);
let full_t = select(ang, ang + TAU, ang < 0.0);
return ClosestResult(q_circ, full_t);
}
// Initial estimate from eccentric anomaly of radial projection
var t = atan2(py * a, px * b);
let k = a * a - b * b;
// 3 iterations of Newton-Raphson for machine precision
for (var i = 0u; i < 3u; i++) {
let st = sin(t);
let ct = cos(t);
let f = k * st * ct - px * a * st + py * b * ct;
let f_prime = k * (ct * ct - st * st) - px * a * ct - py * b * st;
if abs(f_prime) > 1e-7 {
t -= f / f_prime;
}
}
t = clamp(t, 0.0, 1.5707963267948966);
let q_quad = vec2<f32>(a * cos(t), b * sin(t));
let q = vec2<f32>(
select(-q_quad.x, q_quad.x, p_in.x >= 0.0),
select(-q_quad.y, q_quad.y, p_in.y >= 0.0)
);
var full_t = t;
if (p_in.x < 0.0) {
full_t = 3.141592653589793 - t;
}
if (p_in.y < 0.0) {
full_t = select(TAU - t, 3.141592653589793 + t, p_in.x < 0.0);
}
return ClosestResult(q, full_t);
}
fn in_dash(dist: f32, pat_len: f32, p0: vec4<f32>, p1: vec4<f32>) -> bool {
let elems = array<f32, 8>(p0.x, p0.y, p0.z, p0.w, p1.x, p1.y, p1.z, p1.w);
var count = 0u;
for (var i = 0u; i < 8u; i++) {
if elems[i] != 0.0 { count = i + 1u; }
}
if count == 0u {
return true;
}
let d = ((dist % pat_len) + pat_len) % pat_len;
var pos = 0.0f;
let dot_half = u.world_per_pixel * 0.75;
for (var i = 0u; i < count; i++) {
let elem = elems[i];
if elem == 0.0 {
let dd = abs(d - pos);
if min(dd, pat_len - dd) <= dot_half { return true; }
} else if elem > 0.0 {
if d >= pos && d < pos + elem { return true; }
pos += elem;
} else {
pos += -elem;
}
}
return false;
}
@fragment fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
let ab = in.semi_axes;
let res = closest_point_ellipse(in.local_pos, ab);
let q = res.q;
let t = res.t;
let sa = in.start_param;
let ea = in.end_param;
var sweep = mod_tau(ea - sa);
if sweep <= 1e-5 && abs(ea - sa) > 1e-5 {
sweep = TAU;
}
let d_t = mod_tau(t - sa);
var d_world: f32;
var arc_dist: f32;
// Ramanujan approximation of ellipse perimeter for dash scaling
let h_val = ((ab.x - ab.y) * (ab.x - ab.y)) / max((ab.x + ab.y) * (ab.x + ab.y), 1e-6);
let perimeter = 3.141592653589793 * (ab.x + ab.y) * (1.0 + (3.0 * h_val) / max(10.0 + sqrt(max(4.0 - 3.0 * h_val, 0.0)), 1e-6));
if sweep >= TAU - 1e-5 || d_t <= sweep {
d_world = length(in.local_pos - q);
arc_dist = perimeter * (d_t / TAU);
} else {
let p_start = vec2<f32>(ab.x * cos(sa), ab.y * sin(sa));
let p_end = vec2<f32>(ab.x * cos(ea), ab.y * sin(ea));
let d_start = length(in.local_pos - p_start);
let d_end = length(in.local_pos - p_end);
if d_start < d_end {
d_world = d_start;
arc_dist = 0.0;
} else {
d_world = d_end;
arc_dist = perimeter * (sweep / TAU);
}
}
// Isotropic quad screen-space filter width:
// Deriving from interpolated linear coordinates eliminates scanline derivative cancellations
let dx = dpdx(in.local_pos);
let dy = dpdy(in.local_pos);
let fw_len = sqrt(0.5 * (dot(dx, dx) + dot(dy, dy)));
let fw = max(select(u.world_per_pixel, fw_len, fw_len > 1e-6), 1e-6);
let d_px = d_world / fw;
let alpha_cov = clamp(0.5 + in.hw_px - d_px, 0.0, 1.0);
if alpha_cov <= 0.0 {
discard;
}
if in.pattern_length > 0.0 {
let pat_len = in.pattern_length * u.linetype_scale;
if in.min_elem * u.linetype_scale >= u.world_per_pixel && pat_len > 0.0 {
if !in_dash(arc_dist, pat_len, in.pat0, in.pat1) {
discard;
}
}
}
let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5) * alpha_cov;
return vec4<f32>(in.color.rgb, alpha);
}
+2
View File
@@ -1778,6 +1778,7 @@ impl Snapper {
.fold(f32::INFINITY, f32::min);
(world, edge_d2)
}
TangentGeom::PlanarEllipse { .. } => continue,
};
let (tier, sub) = (
snap_tier(SnapType::Tangent),
@@ -1814,6 +1815,7 @@ impl Snapper {
radius: *radius,
}
}
TangentGeom::PlanarEllipse { .. } => unreachable!(),
};
best = Some(SnapResult {
world: world_pt,