Another project
1use crate::gpu::Gpu;
2
3pub const MAX_PLANES: usize = 8;
4
5pub const MAX_STROKE_POINTS: usize = 10;
6
7const STROKE_POINT_SLOTS: usize = MAX_STROKE_POINTS / 2;
8
9#[repr(C, align(16))]
10#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
11pub 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
19impl 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)]
46pub 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
53impl 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)]
95struct VectorFrame {
96 viewport_px: [f32; 2],
97 pad: [f32; 2],
98}
99
100const FRAME_SIZE: u64 = core::mem::size_of::<VectorFrame>() as u64;
101const INITIAL_CAP_BYTES: u64 = 256 * 64;
102const VERTS_PER_INSTANCE: u32 = 6;
103
104const 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
110const 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
115struct 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
126impl 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
276fn 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
285pub struct ConvexPolyPipeline(ScreenInstancePipeline);
286
287impl 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
314impl 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
320pub struct StrokePipeline(ScreenInstancePipeline);
321
322impl 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
349impl 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)]
356mod tests {
357 use super::*;
358 use crate::{AdapterPolicy, 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, AdapterPolicy::Platform))
419 else {
420 return;
421 };
422 let mut stroke = StrokePipeline::new(ctx.gpu(), ctx.color_format());
423 let Some(bend) = StrokeInstance::new(
424 &[[8.0, 8.0], [8.0, 56.0], [56.0, 56.0]],
425 [0.0, 0.5, 0.0, 0.5],
426 3.0,
427 ) else {
428 panic!("three points form a stroke");
429 };
430 stroke.upload([64.0, 64.0], &[bend]);
431 let Ok(frame) = ctx.render(|encoder, color, _pick| {
432 clear_black(encoder, color);
433 stroke.draw_range(encoder, color, 0..1);
434 }) else {
435 panic!("offscreen render failed");
436 };
437 let data = frame.rgba();
438 let px = |x: u32, y: u32| {
439 let i = ((y * 64 + x) * 4) as usize;
440 [data[i], data[i + 1], data[i + 2], data[i + 3]]
441 };
442 let joint = px(8, 56);
443 let straight = px(8, 32);
444 assert!(
445 joint[1].abs_diff(straight[1]) <= 8,
446 "the joint must not double-blend, joint {joint:?} vs straight {straight:?}",
447 );
448 }
449
450 #[test]
451 fn convex_fill_and_stroke_paint_their_pixels_on_the_gpu() {
452 let extent = ViewportExtent::square(ViewportPx::new(64));
453 let Ok(ctx) = pollster::block_on(OffscreenContext::new(extent, AdapterPolicy::Platform))
454 else {
455 return;
456 };
457 let mut convex = ConvexPolyPipeline::new(ctx.gpu(), ctx.color_format());
458 let mut stroke = StrokePipeline::new(ctx.gpu(), ctx.color_format());
459 let Some(square) = ConvexInstance::new(
460 [15.0, 15.0, 34.0, 34.0],
461 [1.0, 0.0, 0.0, 1.0],
462 [0.0, 0.0, 0.0, 0.0],
463 0.0,
464 &[
465 [-1.0, 0.0, -16.0],
466 [1.0, 0.0, 48.0],
467 [0.0, -1.0, -16.0],
468 [0.0, 1.0, 48.0],
469 ],
470 ) else {
471 panic!("four planes are within the cap");
472 };
473 let Some(line) =
474 StrokeInstance::new(&[[8.0, 32.0], [56.0, 32.0]], [0.0, 1.0, 0.0, 1.0], 2.0)
475 else {
476 panic!("two points form a stroke");
477 };
478 convex.upload([64.0, 64.0], &[square]);
479 stroke.upload([64.0, 64.0], &[line]);
480 let Ok(frame) = ctx.render(|encoder, color, _pick| {
481 clear_black(encoder, color);
482 convex.draw_range(encoder, color, 0..1);
483 stroke.draw_range(encoder, color, 0..1);
484 }) else {
485 panic!("offscreen render failed");
486 };
487 let data = frame.rgba();
488 let px = |x: u32, y: u32| {
489 let i = ((y * 64 + x) * 4) as usize;
490 [data[i], data[i + 1], data[i + 2], data[i + 3]]
491 };
492 let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(p, q)| p.abs_diff(q) <= 8);
493 assert!(
494 near(px(24, 24), [255, 0, 0, 255]),
495 "convex interior must paint the fill color, got {:?}",
496 px(24, 24),
497 );
498 assert!(
499 near(px(52, 32), [0, 255, 0, 255]),
500 "the stroke past the square must paint the stroke color, got {:?}",
501 px(52, 32),
502 );
503 assert!(
504 near(px(4, 4), [0, 0, 0, 255]),
505 "an empty corner stays the cleared background, got {:?}",
506 px(4, 4),
507 );
508 }
509}