Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

render: stroke & convex poly vector pipelines w/ view-direction

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (Jun 11, 2026, 4:31 PM +0300) commit 029586de parent 7a497d3b change-id wxnqoyut
+768 -6
+57
crates/bone-render/src/camera3.rs
··· 16 16 const ARCBALL_MIN_AXIS: f64 = 1.0e-9; 17 17 const FRAME_MARGIN: f64 = 1.2; 18 18 const SILHOUETTE_COLLAPSE_FRACTION: f64 = 1.0e-6; 19 + const NEAR_PARALLEL_DOT: f64 = 0.999; 19 20 20 21 #[derive(Copy, Clone, Debug, PartialEq)] 21 22 pub struct ViewportPoint { ··· 221 222 } 222 223 }; 223 224 frame_along(aabb, extent, from_center_to_eye, up) 225 + } 226 + 227 + pub fn frame_view_direction( 228 + aabb: Aabb3, 229 + extent: ViewportExtent, 230 + from_center_to_eye: UnitVec3, 231 + ) -> Result<Camera3> { 232 + frame_along( 233 + aabb, 234 + extent, 235 + from_center_to_eye, 236 + stable_up(from_center_to_eye), 237 + ) 238 + } 239 + 240 + fn stable_up(direction: UnitVec3) -> UnitVec3 { 241 + if direction.dot(UnitVec3::z_axis()).abs() > NEAR_PARALLEL_DOT { 242 + UnitVec3::y_axis() 243 + } else { 244 + UnitVec3::z_axis() 245 + } 224 246 } 225 247 226 248 fn frame_along( ··· 933 955 assert!(tz > 0.0, "top looks from +Z"); 934 956 let (_, _, bz) = offset(Bottom); 935 957 assert!(bz < 0.0, "bottom looks from -Z"); 958 + } 959 + 960 + fn direction(x: f64, y: f64, z: f64) -> UnitVec3 { 961 + let Ok(dir) = UnitVec3::try_from_components(x, y, z, RAY_TOLERANCE) else { 962 + panic!("the components are non-degenerate"); 963 + }; 964 + dir 965 + } 966 + 967 + #[test] 968 + fn frame_view_direction_looks_at_the_box_center_from_the_named_octant() { 969 + let box_center = box_aabb().center(); 970 + let (cx, cy, cz) = box_center.coords_mm(); 971 + let Ok(camera) = frame_view_direction(box_aabb(), extent(), direction(1.0, 1.0, 1.0)) 972 + else { 973 + panic!("a corner direction frames a non-degenerate box"); 974 + }; 975 + assert!(close(focal(camera, center()), box_center, 1e-6)); 976 + let (ex, ey, ez) = camera.eye().coords_mm(); 977 + assert!( 978 + ex > cx && ey > cy && ez > cz, 979 + "the +++ corner seats the eye in the positive octant", 980 + ); 981 + } 982 + 983 + #[test] 984 + fn frame_view_direction_handles_the_z_axis_where_up_would_collapse() { 985 + [direction(0.0, 0.0, 1.0), direction(0.0, 0.0, -1.0)] 986 + .into_iter() 987 + .for_each(|dir| { 988 + assert!( 989 + frame_view_direction(box_aabb(), extent(), dir).is_ok(), 990 + "framing along {dir} picks a stable up rather than collapsing", 991 + ); 992 + }); 936 993 } 937 994 938 995 #[test]
+9 -6
crates/bone-render/src/lib.rs
··· 14 14 pub use camera::{Camera2, GridSpacing, PixelsPerMm, ViewportExtent, ViewportPx, ViewportRegion}; 15 15 pub use camera3::{ 16 16 ViewportPoint, arcball_rotation, clip_from_world, frame_current, frame_isometric, 17 - frame_standard_view, orbit_about_pixel, orbit_about_point, orbit_pitch, orbit_yaw, pan_pixels, 18 - roll_about_view, roll_by, world_from_clip, world_on_focal_plane, world_ray, zoom_about_pixel, 17 + frame_standard_view, frame_view_direction, orbit_about_pixel, orbit_about_point, orbit_pitch, 18 + orbit_yaw, pan_pixels, roll_about_view, roll_by, world_from_clip, world_on_focal_plane, 19 + world_ray, zoom_about_pixel, 19 20 }; 20 21 pub use diff::{PixelDiff, PixelDiffError, PixelDiffReport, PixelDiffThreshold, PixelMismatch}; 21 22 pub use gpu::{BackendTag, Capabilities, Gpu, OffscreenContext}; ··· 24 25 EntityKindTag, PickAperture, PickId, PickIdError, PickIndex, PickQuery, PickedItem, Picker, 25 26 }; 26 27 pub use pipelines::{ 27 - ArcPipeline, ChromeInstance, ChromePipeline, ChromeTextPipeline, GlyphPipeline, GridPipeline, 28 - LinesPipeline, SdfGlyphInstance, TextPipeline, 28 + ArcPipeline, ChromeInstance, ChromePipeline, ChromeTextPipeline, ConvexInstance, 29 + ConvexPolyPipeline, GlyphPipeline, GridPipeline, LinesPipeline, MAX_PLANES, MAX_STROKE_POINTS, 30 + SdfGlyphInstance, StrokeInstance, StrokePipeline, TextPipeline, 29 31 }; 30 32 pub(crate) use pipelines::{ 31 33 Edge3dPipeline, EdgeProjection, EdgeView, FaceFill, HiddenEdges, SolidPipeline, SolidView, ··· 42 44 pub use surface::{SurfaceContext, SurfaceError}; 43 45 pub use tween::CameraTween; 44 46 47 + #[must_use] 45 48 #[allow( 46 49 clippy::cast_possible_truncation, 47 50 clippy::cast_precision_loss, 48 - reason = "GPU upload narrows f64 geometry to f32 through this single funnel" 51 + reason = "f64 narrows to f32 through this single funnel" 49 52 )] 50 - pub(crate) fn lower_f32(value: f64) -> f32 { 53 + pub fn lower_f32(value: f64) -> f32 { 51 54 value as f32 52 55 } 53 56
+102
crates/bone-render/src/pipelines/convex_poly.wgsl
··· 1 + struct Frame { 2 + viewport_px: vec2<f32>, 3 + _pad: vec2<f32>, 4 + }; 5 + 6 + @group(0) @binding(0) var<uniform> u: Frame; 7 + 8 + struct VsOut { 9 + @builtin(position) clip: vec4<f32>, 10 + @location(0) pos_and_params: vec4<f32>, 11 + @location(1) fill: vec4<f32>, 12 + @location(2) border: vec4<f32>, 13 + @location(3) @interpolate(flat) e0: vec4<f32>, 14 + @location(4) @interpolate(flat) e1: vec4<f32>, 15 + @location(5) @interpolate(flat) e2: vec4<f32>, 16 + @location(6) @interpolate(flat) e3: vec4<f32>, 17 + @location(7) @interpolate(flat) e4: vec4<f32>, 18 + @location(8) @interpolate(flat) e5: vec4<f32>, 19 + @location(9) @interpolate(flat) e6: vec4<f32>, 20 + @location(10) @interpolate(flat) e7: vec4<f32>, 21 + }; 22 + 23 + const CORNERS: array<vec2<f32>, 6> = array<vec2<f32>, 6>( 24 + vec2<f32>(0.0, 0.0), 25 + vec2<f32>(1.0, 0.0), 26 + vec2<f32>(0.0, 1.0), 27 + vec2<f32>(1.0, 0.0), 28 + vec2<f32>(1.0, 1.0), 29 + vec2<f32>(0.0, 1.0), 30 + ); 31 + 32 + @vertex 33 + fn vs( 34 + @builtin(vertex_index) vid: u32, 35 + @location(0) rect_xywh: vec4<f32>, 36 + @location(1) fill: vec4<f32>, 37 + @location(2) border: vec4<f32>, 38 + @location(3) params: vec4<f32>, 39 + @location(4) e0: vec4<f32>, 40 + @location(5) e1: vec4<f32>, 41 + @location(6) e2: vec4<f32>, 42 + @location(7) e3: vec4<f32>, 43 + @location(8) e4: vec4<f32>, 44 + @location(9) e5: vec4<f32>, 45 + @location(10) e6: vec4<f32>, 46 + @location(11) e7: vec4<f32>, 47 + ) -> VsOut { 48 + var corners = CORNERS; 49 + let c = corners[vid]; 50 + let pos_px = rect_xywh.xy + c * rect_xywh.zw; 51 + let ndc = vec2<f32>( 52 + (pos_px.x / u.viewport_px.x) * 2.0 - 1.0, 53 + 1.0 - (pos_px.y / u.viewport_px.y) * 2.0, 54 + ); 55 + var out: VsOut; 56 + out.clip = vec4<f32>(ndc, 0.0, 1.0); 57 + out.pos_and_params = vec4<f32>(pos_px, params.x, params.y); 58 + out.fill = fill; 59 + out.border = border; 60 + out.e0 = e0; 61 + out.e1 = e1; 62 + out.e2 = e2; 63 + out.e3 = e3; 64 + out.e4 = e4; 65 + out.e5 = e5; 66 + out.e6 = e6; 67 + out.e7 = e7; 68 + return out; 69 + } 70 + 71 + fn plane_sd(p: vec2<f32>, plane: vec4<f32>) -> f32 { 72 + return dot(plane.xy, p) - plane.z; 73 + } 74 + 75 + @fragment 76 + fn fs(in: VsOut) -> @location(0) vec4<f32> { 77 + let p = in.pos_and_params.xy; 78 + let count = in.pos_and_params.z; 79 + let border_w = in.pos_and_params.w; 80 + var sd = -1.0e30; 81 + if (count > 0.5) { sd = max(sd, plane_sd(p, in.e0)); } 82 + if (count > 1.5) { sd = max(sd, plane_sd(p, in.e1)); } 83 + if (count > 2.5) { sd = max(sd, plane_sd(p, in.e2)); } 84 + if (count > 3.5) { sd = max(sd, plane_sd(p, in.e3)); } 85 + if (count > 4.5) { sd = max(sd, plane_sd(p, in.e4)); } 86 + if (count > 5.5) { sd = max(sd, plane_sd(p, in.e5)); } 87 + if (count > 6.5) { sd = max(sd, plane_sd(p, in.e6)); } 88 + if (count > 7.5) { sd = max(sd, plane_sd(p, in.e7)); } 89 + let aa = 0.5; 90 + let cov_outer = clamp(0.5 - sd / aa, 0.0, 1.0); 91 + if (cov_outer <= 0.0) { 92 + discard; 93 + } 94 + var cov_inner = cov_outer; 95 + if (border_w > 0.0) { 96 + cov_inner = clamp(0.5 - (sd + border_w) / aa, 0.0, 1.0); 97 + } 98 + let border_cov = max(cov_outer - cov_inner, 0.0); 99 + let bp = in.border * border_cov; 100 + let fp = in.fill * cov_inner; 101 + return bp + fp * (1.0 - bp.a); 102 + }
+5
crates/bone-render/src/pipelines/mod.rs
··· 8 8 pub mod solid; 9 9 pub mod text; 10 10 mod text_common; 11 + pub mod vector; 11 12 12 13 pub use arc::ArcPipeline; 13 14 pub use chrome::{ChromeInstance, ChromePipeline}; ··· 18 19 pub use lines::LinesPipeline; 19 20 pub(crate) use solid::{FaceFill, SolidPipeline, SolidView}; 20 21 pub use text::TextPipeline; 22 + pub use vector::{ 23 + ConvexInstance, ConvexPolyPipeline, MAX_PLANES, MAX_STROKE_POINTS, StrokeInstance, 24 + StrokePipeline, 25 + }; 21 26 22 27 use crate::camera::Camera2; 23 28 use crate::snapshot::Style;
+88
crates/bone-render/src/pipelines/stroke.wgsl
··· 1 + struct Frame { 2 + viewport_px: vec2<f32>, 3 + _pad: vec2<f32>, 4 + }; 5 + 6 + @group(0) @binding(0) var<uniform> u: Frame; 7 + 8 + struct VsOut { 9 + @builtin(position) clip: vec4<f32>, 10 + @location(0) pos_px: vec2<f32>, 11 + @location(1) @interpolate(flat) count_half: vec2<f32>, 12 + @location(2) @interpolate(flat) color: vec4<f32>, 13 + @location(3) @interpolate(flat) p0: vec4<f32>, 14 + @location(4) @interpolate(flat) p1: vec4<f32>, 15 + @location(5) @interpolate(flat) p2: vec4<f32>, 16 + @location(6) @interpolate(flat) p3: vec4<f32>, 17 + @location(7) @interpolate(flat) p4: vec4<f32>, 18 + }; 19 + 20 + const CORNERS: array<vec2<f32>, 6> = array<vec2<f32>, 6>( 21 + vec2<f32>(0.0, 0.0), 22 + vec2<f32>(1.0, 0.0), 23 + vec2<f32>(0.0, 1.0), 24 + vec2<f32>(1.0, 0.0), 25 + vec2<f32>(1.0, 1.0), 26 + vec2<f32>(0.0, 1.0), 27 + ); 28 + 29 + @vertex 30 + fn vs( 31 + @builtin(vertex_index) vid: u32, 32 + @location(0) rect_xywh: vec4<f32>, 33 + @location(1) color: vec4<f32>, 34 + @location(2) params: vec4<f32>, 35 + @location(3) p0: vec4<f32>, 36 + @location(4) p1: vec4<f32>, 37 + @location(5) p2: vec4<f32>, 38 + @location(6) p3: vec4<f32>, 39 + @location(7) p4: vec4<f32>, 40 + ) -> VsOut { 41 + var corners = CORNERS; 42 + let c = corners[vid]; 43 + let pos_px = rect_xywh.xy + c * rect_xywh.zw; 44 + let ndc = vec2<f32>( 45 + (pos_px.x / u.viewport_px.x) * 2.0 - 1.0, 46 + 1.0 - (pos_px.y / u.viewport_px.y) * 2.0, 47 + ); 48 + var out: VsOut; 49 + out.clip = vec4<f32>(ndc, 0.0, 1.0); 50 + out.pos_px = pos_px; 51 + out.count_half = params.xy; 52 + out.color = color; 53 + out.p0 = p0; 54 + out.p1 = p1; 55 + out.p2 = p2; 56 + out.p3 = p3; 57 + out.p4 = p4; 58 + return out; 59 + } 60 + 61 + fn seg_dist(p: vec2<f32>, a: vec2<f32>, b: vec2<f32>) -> f32 { 62 + let d = b - a; 63 + let len2 = max(dot(d, d), 1.0e-12); 64 + let t = clamp(dot(p - a, d) / len2, 0.0, 1.0); 65 + return length(p - (a + t * d)); 66 + } 67 + 68 + @fragment 69 + fn fs(in: VsOut) -> @location(0) vec4<f32> { 70 + let p = in.pos_px; 71 + let count = in.count_half.x; 72 + let half_px = in.count_half.y; 73 + var d = seg_dist(p, in.p0.xy, in.p0.zw); 74 + if (count > 2.5) { d = min(d, seg_dist(p, in.p0.zw, in.p1.xy)); } 75 + if (count > 3.5) { d = min(d, seg_dist(p, in.p1.xy, in.p1.zw)); } 76 + if (count > 4.5) { d = min(d, seg_dist(p, in.p1.zw, in.p2.xy)); } 77 + if (count > 5.5) { d = min(d, seg_dist(p, in.p2.xy, in.p2.zw)); } 78 + if (count > 6.5) { d = min(d, seg_dist(p, in.p2.zw, in.p3.xy)); } 79 + if (count > 7.5) { d = min(d, seg_dist(p, in.p3.xy, in.p3.zw)); } 80 + if (count > 8.5) { d = min(d, seg_dist(p, in.p3.zw, in.p4.xy)); } 81 + if (count > 9.5) { d = min(d, seg_dist(p, in.p4.xy, in.p4.zw)); } 82 + let aa = 0.5; 83 + let cov = clamp(0.5 - (d - half_px) / aa, 0.0, 1.0); 84 + if (cov <= 0.0) { 85 + discard; 86 + } 87 + return in.color * cov; 88 + }
+507
crates/bone-render/src/pipelines/vector.rs
··· 1 + use crate::gpu::Gpu; 2 + 3 + pub const MAX_PLANES: usize = 8; 4 + 5 + pub const MAX_STROKE_POINTS: usize = 10; 6 + 7 + const STROKE_POINT_SLOTS: usize = MAX_STROKE_POINTS / 2; 8 + 9 + #[repr(C, align(16))] 10 + #[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] 11 + pub struct ConvexInstance { 12 + pub rect_xywh_px: [f32; 4], 13 + pub fill_premul_rgba: [f32; 4], 14 + pub border_premul_rgba: [f32; 4], 15 + pub count_border_pad: [f32; 4], 16 + pub planes: [[f32; 4]; MAX_PLANES], 17 + } 18 + 19 + impl ConvexInstance { 20 + #[must_use] 21 + pub fn new( 22 + rect_xywh_px: [f32; 4], 23 + fill_premul_rgba: [f32; 4], 24 + border_premul_rgba: [f32; 4], 25 + border_width_px: f32, 26 + planes: &[[f32; 3]], 27 + ) -> Option<Self> { 28 + if !(1..=MAX_PLANES).contains(&planes.len()) { 29 + return None; 30 + } 31 + let count = u8::try_from(planes.len()).ok()?; 32 + let packed = 33 + core::array::from_fn(|i| planes.get(i).map_or([0.0; 4], |p| [p[0], p[1], p[2], 0.0])); 34 + Some(Self { 35 + rect_xywh_px, 36 + fill_premul_rgba, 37 + border_premul_rgba, 38 + count_border_pad: [f32::from(count), border_width_px, 0.0, 0.0], 39 + planes: packed, 40 + }) 41 + } 42 + } 43 + 44 + #[repr(C, align(16))] 45 + #[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] 46 + pub struct StrokeInstance { 47 + pub rect_xywh_px: [f32; 4], 48 + pub color_premul_rgba: [f32; 4], 49 + pub count_half_pad: [f32; 4], 50 + pub points_px: [[f32; 4]; STROKE_POINT_SLOTS], 51 + } 52 + 53 + impl StrokeInstance { 54 + #[must_use] 55 + pub fn new( 56 + points_px: &[[f32; 2]], 57 + color_premul_rgba: [f32; 4], 58 + half_width_px: f32, 59 + ) -> Option<Self> { 60 + if !(2..=MAX_STROKE_POINTS).contains(&points_px.len()) { 61 + return None; 62 + } 63 + let count = u8::try_from(points_px.len()).ok()?; 64 + let pad = half_width_px.max(0.0) + 1.0; 65 + let (min, max) = points_px.iter().fold( 66 + ([f32::INFINITY; 2], [f32::NEG_INFINITY; 2]), 67 + |(lo, hi), p| { 68 + ( 69 + [lo[0].min(p[0]), lo[1].min(p[1])], 70 + [hi[0].max(p[0]), hi[1].max(p[1])], 71 + ) 72 + }, 73 + ); 74 + let pair = |i: usize| { 75 + let at = |j: usize| points_px.get(j).copied().unwrap_or([0.0; 2]); 76 + let (a, b) = (at(2 * i), at(2 * i + 1)); 77 + [a[0], a[1], b[0], b[1]] 78 + }; 79 + Some(Self { 80 + rect_xywh_px: [ 81 + min[0] - pad, 82 + min[1] - pad, 83 + max[0] - min[0] + 2.0 * pad, 84 + max[1] - min[1] + 2.0 * pad, 85 + ], 86 + color_premul_rgba, 87 + count_half_pad: [f32::from(count), half_width_px, 0.0, 0.0], 88 + points_px: core::array::from_fn(pair), 89 + }) 90 + } 91 + } 92 + 93 + #[repr(C, align(16))] 94 + #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] 95 + struct VectorFrame { 96 + viewport_px: [f32; 2], 97 + pad: [f32; 2], 98 + } 99 + 100 + const FRAME_SIZE: u64 = core::mem::size_of::<VectorFrame>() as u64; 101 + const INITIAL_CAP_BYTES: u64 = 256 * 64; 102 + const VERTS_PER_INSTANCE: u32 = 6; 103 + 104 + const CONVEX_ATTRS: [wgpu::VertexAttribute; 12] = wgpu::vertex_attr_array![ 105 + 0 => Float32x4, 1 => Float32x4, 2 => Float32x4, 3 => Float32x4, 106 + 4 => Float32x4, 5 => Float32x4, 6 => Float32x4, 7 => Float32x4, 107 + 8 => Float32x4, 9 => Float32x4, 10 => Float32x4, 11 => Float32x4, 108 + ]; 109 + 110 + const STROKE_ATTRS: [wgpu::VertexAttribute; 8] = wgpu::vertex_attr_array![ 111 + 0 => Float32x4, 1 => Float32x4, 2 => Float32x4, 3 => Float32x4, 112 + 4 => Float32x4, 5 => Float32x4, 6 => Float32x4, 7 => Float32x4, 113 + ]; 114 + 115 + struct ScreenInstancePipeline { 116 + device: wgpu::Device, 117 + queue: wgpu::Queue, 118 + pipeline: wgpu::RenderPipeline, 119 + uniform_buffer: wgpu::Buffer, 120 + bind_group: wgpu::BindGroup, 121 + instances: wgpu::Buffer, 122 + capacity_bytes: u64, 123 + stride: u64, 124 + } 125 + 126 + impl ScreenInstancePipeline { 127 + fn new( 128 + gpu: &Gpu, 129 + color_format: wgpu::TextureFormat, 130 + label: &str, 131 + shader_source: &str, 132 + attributes: &[wgpu::VertexAttribute], 133 + stride: u64, 134 + ) -> Self { 135 + let device = gpu.device().clone(); 136 + let queue = gpu.queue().clone(); 137 + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { 138 + label: Some(label), 139 + source: wgpu::ShaderSource::Wgsl(shader_source.into()), 140 + }); 141 + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { 142 + label: Some(label), 143 + entries: &[wgpu::BindGroupLayoutEntry { 144 + binding: 0, 145 + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, 146 + ty: wgpu::BindingType::Buffer { 147 + ty: wgpu::BufferBindingType::Uniform, 148 + has_dynamic_offset: false, 149 + min_binding_size: wgpu::BufferSize::new(FRAME_SIZE), 150 + }, 151 + count: None, 152 + }], 153 + }); 154 + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { 155 + label: Some(label), 156 + bind_group_layouts: &[Some(&bind_group_layout)], 157 + immediate_size: 0, 158 + }); 159 + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { 160 + label: Some(label), 161 + layout: Some(&pipeline_layout), 162 + vertex: wgpu::VertexState { 163 + module: &shader, 164 + entry_point: Some("vs"), 165 + compilation_options: wgpu::PipelineCompilationOptions::default(), 166 + buffers: &[wgpu::VertexBufferLayout { 167 + array_stride: stride, 168 + step_mode: wgpu::VertexStepMode::Instance, 169 + attributes, 170 + }], 171 + }, 172 + fragment: Some(wgpu::FragmentState { 173 + module: &shader, 174 + entry_point: Some("fs"), 175 + compilation_options: wgpu::PipelineCompilationOptions::default(), 176 + targets: &[Some(wgpu::ColorTargetState { 177 + format: color_format, 178 + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), 179 + write_mask: wgpu::ColorWrites::ALL, 180 + })], 181 + }), 182 + primitive: wgpu::PrimitiveState { 183 + topology: wgpu::PrimitiveTopology::TriangleList, 184 + strip_index_format: None, 185 + front_face: wgpu::FrontFace::Ccw, 186 + cull_mode: None, 187 + polygon_mode: wgpu::PolygonMode::Fill, 188 + conservative: false, 189 + unclipped_depth: false, 190 + }, 191 + depth_stencil: None, 192 + multisample: wgpu::MultisampleState::default(), 193 + multiview_mask: None, 194 + cache: None, 195 + }); 196 + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { 197 + label: Some(label), 198 + size: FRAME_SIZE, 199 + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, 200 + mapped_at_creation: false, 201 + }); 202 + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { 203 + label: Some(label), 204 + layout: &bind_group_layout, 205 + entries: &[wgpu::BindGroupEntry { 206 + binding: 0, 207 + resource: uniform_buffer.as_entire_binding(), 208 + }], 209 + }); 210 + let instances = create_instance_buffer(&device, INITIAL_CAP_BYTES); 211 + Self { 212 + device, 213 + queue, 214 + pipeline, 215 + uniform_buffer, 216 + bind_group, 217 + instances, 218 + capacity_bytes: INITIAL_CAP_BYTES, 219 + stride, 220 + } 221 + } 222 + 223 + fn upload(&mut self, viewport_px: [f32; 2], instance_bytes: &[u8]) { 224 + let frame = VectorFrame { 225 + viewport_px, 226 + pad: [0.0, 0.0], 227 + }; 228 + self.queue 229 + .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&frame)); 230 + if instance_bytes.is_empty() { 231 + return; 232 + } 233 + let needed = instance_bytes.len() as u64; 234 + if needed > self.capacity_bytes { 235 + let new_cap = needed.next_power_of_two().max(self.capacity_bytes * 2); 236 + self.instances = create_instance_buffer(&self.device, new_cap); 237 + self.capacity_bytes = new_cap; 238 + } 239 + self.queue.write_buffer(&self.instances, 0, instance_bytes); 240 + } 241 + 242 + fn draw_range( 243 + &self, 244 + encoder: &mut wgpu::CommandEncoder, 245 + color_view: &wgpu::TextureView, 246 + range: core::ops::Range<u32>, 247 + ) { 248 + if range.start >= range.end { 249 + return; 250 + } 251 + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { 252 + label: Some("bone-render:vector-pass"), 253 + color_attachments: &[Some(wgpu::RenderPassColorAttachment { 254 + view: color_view, 255 + resolve_target: None, 256 + depth_slice: None, 257 + ops: wgpu::Operations { 258 + load: wgpu::LoadOp::Load, 259 + store: wgpu::StoreOp::Store, 260 + }, 261 + })], 262 + depth_stencil_attachment: None, 263 + timestamp_writes: None, 264 + occlusion_query_set: None, 265 + multiview_mask: None, 266 + }); 267 + pass.set_pipeline(&self.pipeline); 268 + pass.set_bind_group(0, &self.bind_group, &[]); 269 + let start = u64::from(range.start) * self.stride; 270 + let end = u64::from(range.end) * self.stride; 271 + pass.set_vertex_buffer(0, self.instances.slice(start..end)); 272 + pass.draw(0..VERTS_PER_INSTANCE, 0..(range.end - range.start)); 273 + } 274 + } 275 + 276 + fn create_instance_buffer(device: &wgpu::Device, capacity_bytes: u64) -> wgpu::Buffer { 277 + device.create_buffer(&wgpu::BufferDescriptor { 278 + label: Some("bone-render:vector-instances"), 279 + size: capacity_bytes, 280 + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, 281 + mapped_at_creation: false, 282 + }) 283 + } 284 + 285 + pub struct ConvexPolyPipeline(ScreenInstancePipeline); 286 + 287 + impl ConvexPolyPipeline { 288 + #[must_use] 289 + pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { 290 + Self(ScreenInstancePipeline::new( 291 + gpu, 292 + color_format, 293 + "bone-render:convex-poly", 294 + include_str!("convex_poly.wgsl"), 295 + &CONVEX_ATTRS, 296 + core::mem::size_of::<ConvexInstance>() as u64, 297 + )) 298 + } 299 + 300 + pub fn upload(&mut self, viewport_px: [f32; 2], instances: &[ConvexInstance]) { 301 + self.0.upload(viewport_px, bytemuck::cast_slice(instances)); 302 + } 303 + 304 + pub fn draw_range( 305 + &self, 306 + encoder: &mut wgpu::CommandEncoder, 307 + color_view: &wgpu::TextureView, 308 + range: core::ops::Range<u32>, 309 + ) { 310 + self.0.draw_range(encoder, color_view, range); 311 + } 312 + } 313 + 314 + impl core::fmt::Debug for ConvexPolyPipeline { 315 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 316 + f.debug_struct("ConvexPolyPipeline").finish_non_exhaustive() 317 + } 318 + } 319 + 320 + pub struct StrokePipeline(ScreenInstancePipeline); 321 + 322 + impl StrokePipeline { 323 + #[must_use] 324 + pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { 325 + Self(ScreenInstancePipeline::new( 326 + gpu, 327 + color_format, 328 + "bone-render:stroke", 329 + include_str!("stroke.wgsl"), 330 + &STROKE_ATTRS, 331 + core::mem::size_of::<StrokeInstance>() as u64, 332 + )) 333 + } 334 + 335 + pub fn upload(&mut self, viewport_px: [f32; 2], instances: &[StrokeInstance]) { 336 + self.0.upload(viewport_px, bytemuck::cast_slice(instances)); 337 + } 338 + 339 + pub fn draw_range( 340 + &self, 341 + encoder: &mut wgpu::CommandEncoder, 342 + color_view: &wgpu::TextureView, 343 + range: core::ops::Range<u32>, 344 + ) { 345 + self.0.draw_range(encoder, color_view, range); 346 + } 347 + } 348 + 349 + impl core::fmt::Debug for StrokePipeline { 350 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 351 + f.debug_struct("StrokePipeline").finish_non_exhaustive() 352 + } 353 + } 354 + 355 + #[cfg(test)] 356 + mod tests { 357 + use super::*; 358 + use crate::{OffscreenContext, ViewportExtent, ViewportPx}; 359 + 360 + fn clear_black(encoder: &mut wgpu::CommandEncoder, color: &wgpu::TextureView) { 361 + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { 362 + label: Some("vector-test-clear"), 363 + color_attachments: &[Some(wgpu::RenderPassColorAttachment { 364 + view: color, 365 + resolve_target: None, 366 + depth_slice: None, 367 + ops: wgpu::Operations { 368 + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), 369 + store: wgpu::StoreOp::Store, 370 + }, 371 + })], 372 + depth_stencil_attachment: None, 373 + timestamp_writes: None, 374 + occlusion_query_set: None, 375 + multiview_mask: None, 376 + }); 377 + } 378 + 379 + #[test] 380 + fn convex_instance_rejects_empty_and_oversized_plane_sets() { 381 + let unit = [1.0, 0.0, 0.0]; 382 + let rect = [0.0, 0.0, 8.0, 8.0]; 383 + let color = [1.0, 0.0, 0.0, 1.0]; 384 + assert!(ConvexInstance::new(rect, color, color, 0.0, &[]).is_none()); 385 + assert!(ConvexInstance::new(rect, color, color, 0.0, &[unit; MAX_PLANES + 1]).is_none()); 386 + assert!(ConvexInstance::new(rect, color, color, 0.0, &[unit; MAX_PLANES]).is_some()); 387 + } 388 + 389 + #[test] 390 + fn stroke_instance_rejects_too_few_and_too_many_points() { 391 + let color = [1.0, 0.0, 0.0, 1.0]; 392 + assert!(StrokeInstance::new(&[[0.0, 0.0]], color, 1.0).is_none()); 393 + assert!(StrokeInstance::new(&[[0.0, 0.0]; MAX_STROKE_POINTS + 1], color, 1.0).is_none()); 394 + assert!(StrokeInstance::new(&[[0.0, 0.0]; MAX_STROKE_POINTS], color, 1.0).is_some()); 395 + } 396 + 397 + #[test] 398 + fn stroke_instance_bounds_cover_every_point_plus_width_and_aa() { 399 + let Some(line) = StrokeInstance::new(&[[10.0, 20.0], [40.0, 5.0]], [0.0; 4], 3.0) else { 400 + panic!("two points form a stroke"); 401 + }; 402 + let expected = [6.0, 1.0, 38.0, 23.0]; 403 + line.rect_xywh_px 404 + .iter() 405 + .zip(expected) 406 + .for_each(|(got, want)| { 407 + assert!( 408 + (got - want).abs() < 1.0e-6, 409 + "stroke rect {:?} must match {expected:?}", 410 + line.rect_xywh_px, 411 + ); 412 + }); 413 + } 414 + 415 + #[test] 416 + fn a_translucent_polyline_joint_blends_once_on_the_gpu() { 417 + let extent = ViewportExtent::square(ViewportPx::new(64)); 418 + let Ok(ctx) = pollster::block_on(OffscreenContext::new(extent)) else { 419 + return; 420 + }; 421 + let mut stroke = StrokePipeline::new(ctx.gpu(), ctx.color_format()); 422 + let Some(bend) = StrokeInstance::new( 423 + &[[8.0, 8.0], [8.0, 56.0], [56.0, 56.0]], 424 + [0.0, 0.5, 0.0, 0.5], 425 + 3.0, 426 + ) else { 427 + panic!("three points form a stroke"); 428 + }; 429 + stroke.upload([64.0, 64.0], &[bend]); 430 + let Ok(frame) = ctx.render(|encoder, color, _pick| { 431 + clear_black(encoder, color); 432 + stroke.draw_range(encoder, color, 0..1); 433 + }) else { 434 + panic!("offscreen render failed"); 435 + }; 436 + let data = frame.rgba(); 437 + let px = |x: u32, y: u32| { 438 + let i = ((y * 64 + x) * 4) as usize; 439 + [data[i], data[i + 1], data[i + 2], data[i + 3]] 440 + }; 441 + let joint = px(8, 56); 442 + let straight = px(8, 32); 443 + assert!( 444 + joint[1].abs_diff(straight[1]) <= 8, 445 + "the joint must not double-blend, joint {joint:?} vs straight {straight:?}", 446 + ); 447 + } 448 + 449 + #[test] 450 + fn convex_fill_and_stroke_paint_their_pixels_on_the_gpu() { 451 + let extent = ViewportExtent::square(ViewportPx::new(64)); 452 + let Ok(ctx) = pollster::block_on(OffscreenContext::new(extent)) else { 453 + return; 454 + }; 455 + let mut convex = ConvexPolyPipeline::new(ctx.gpu(), ctx.color_format()); 456 + let mut stroke = StrokePipeline::new(ctx.gpu(), ctx.color_format()); 457 + let Some(square) = ConvexInstance::new( 458 + [15.0, 15.0, 34.0, 34.0], 459 + [1.0, 0.0, 0.0, 1.0], 460 + [0.0, 0.0, 0.0, 0.0], 461 + 0.0, 462 + &[ 463 + [-1.0, 0.0, -16.0], 464 + [1.0, 0.0, 48.0], 465 + [0.0, -1.0, -16.0], 466 + [0.0, 1.0, 48.0], 467 + ], 468 + ) else { 469 + panic!("four planes are within the cap"); 470 + }; 471 + let Some(line) = 472 + StrokeInstance::new(&[[8.0, 32.0], [56.0, 32.0]], [0.0, 1.0, 0.0, 1.0], 2.0) 473 + else { 474 + panic!("two points form a stroke"); 475 + }; 476 + convex.upload([64.0, 64.0], &[square]); 477 + stroke.upload([64.0, 64.0], &[line]); 478 + let Ok(frame) = ctx.render(|encoder, color, _pick| { 479 + clear_black(encoder, color); 480 + convex.draw_range(encoder, color, 0..1); 481 + stroke.draw_range(encoder, color, 0..1); 482 + }) else { 483 + panic!("offscreen render failed"); 484 + }; 485 + let data = frame.rgba(); 486 + let px = |x: u32, y: u32| { 487 + let i = ((y * 64 + x) * 4) as usize; 488 + [data[i], data[i + 1], data[i + 2], data[i + 3]] 489 + }; 490 + let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(p, q)| p.abs_diff(q) <= 8); 491 + assert!( 492 + near(px(24, 24), [255, 0, 0, 255]), 493 + "convex interior must paint the fill color, got {:?}", 494 + px(24, 24), 495 + ); 496 + assert!( 497 + near(px(52, 32), [0, 255, 0, 255]), 498 + "the stroke past the square must paint the stroke color, got {:?}", 499 + px(52, 32), 500 + ); 501 + assert!( 502 + near(px(4, 4), [0, 0, 0, 255]), 503 + "an empty corner stays the cleared background, got {:?}", 504 + px(4, 4), 505 + ); 506 + } 507 + }