1use std::f64::consts::TAU;
22
23use bytemuck::{Pod, Zeroable};
24use wgpu::util::DeviceExt;
25
26use brepkit_math::surfaces::CylindricalSurface;
27use brepkit_math::vec::{Point3, Vec3};
28use brepkit_topology::Topology;
29use brepkit_topology::face::{FaceId, FaceSurface};
30
31use crate::camera::Camera;
32use crate::error::RenderError;
33use crate::pipeline;
34use crate::{RenderOpts, RenderOutput};
35
36const MAX_TESS: u32 = 16_384;
43
44const WORDS_PER_VERT: u64 = 7;
48
49#[derive(Debug, Clone, Copy)]
56pub struct TessFactor {
57 pub n_u: u32,
59 pub n_v: u32,
61}
62
63impl TessFactor {
64 #[must_use]
68 pub fn new(n_u: u32, n_v: u32) -> Self {
69 Self {
70 n_u: n_u.clamp(3, MAX_TESS),
71 n_v: n_v.clamp(1, MAX_TESS),
72 }
73 }
74}
75
76pub const DEFAULT_TARGET_PX: f64 = 0.5;
81
82#[must_use]
110pub fn screen_space_tess_factor(
111 desc: &CylinderDescriptor,
112 cam: &Camera,
113 viewport: (u32, u32),
114 target_px: f64,
115) -> TessFactor {
116 let n_u = angular_subdivisions_for_screen_error(desc, cam, viewport, target_px);
117 TessFactor::new(n_u, 1)
118}
119
120fn angular_subdivisions_for_screen_error(
128 desc: &CylinderDescriptor,
129 cam: &Camera,
130 viewport: (u32, u32),
131 target_px: f64,
132) -> u32 {
133 let (_, height) = viewport;
134
135 let fov_y = cam.fov_y.clamp(1.0e-4, std::f64::consts::PI - 1.0e-4);
139 let half_fov_tan = (fov_y * 0.5).tan();
140
141 let depth = cam.view_direction().dot(desc.center - cam.eye);
145
146 if !(target_px.is_finite() && target_px > 0.0) {
148 return MAX_TESS;
149 }
150 let r_px = desc.radius * (f64::from(height) * 0.5) / (depth * half_fov_tan);
151 if r_px.is_infinite() && r_px > 0.0 {
156 return MAX_TESS;
157 }
158 if !(r_px.is_finite() && r_px > 0.0) {
159 return 3;
160 }
161
162 let ratio = (target_px / r_px).clamp(0.0, 2.0);
164 let theta = (1.0 - ratio).acos(); if !(theta.is_finite() && theta > 0.0) {
166 return 3;
168 }
169 let n = (std::f64::consts::PI / theta).ceil();
170 if n >= f64::from(MAX_TESS) {
172 MAX_TESS
173 } else {
174 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
176 let v = n as u32;
177 v
178 }
179}
180
181#[derive(Debug, Clone, Copy)]
188pub struct CylinderDescriptor {
189 pub center: Point3,
192 pub axis_origin: Point3,
194 pub axis: Vec3,
196 pub x_ref: Vec3,
198 pub y_ref: Vec3,
200 pub radius: f64,
202 pub v0: f64,
204 pub v1: f64,
206 pub u0: f64,
208 pub u1: f64,
211}
212
213impl CylinderDescriptor {
214 #[must_use]
222 pub fn evaluate(&self, u: f64, v: f64) -> Point3 {
223 let radial = self.x_ref * (self.radius * u.cos()) + self.y_ref * (self.radius * u.sin());
224 self.axis_origin + radial + self.axis * v
225 }
226
227 fn aabb(&self) -> (Point3, Point3) {
230 let mut min = [f64::INFINITY; 3];
231 let mut max = [f64::NEG_INFINITY; 3];
232 let samples = 64;
233 for k in 0..=samples {
234 let t = f64::from(k) / f64::from(samples);
235 let u = self.u0 + (self.u1 - self.u0) * t;
236 for &v in &[self.v0, self.v1] {
237 let p = self.evaluate(u, v);
238 let c = [p.x(), p.y(), p.z()];
239 for axis in 0..3 {
240 min[axis] = min[axis].min(c[axis]);
241 max[axis] = max[axis].max(c[axis]);
242 }
243 }
244 }
245 (
246 Point3::new(min[0], min[1], min[2]),
247 Point3::new(max[0], max[1], max[2]),
248 )
249 }
250
251 #[must_use]
253 pub fn triangle_count(tess: TessFactor) -> usize {
254 2 * tess.n_u as usize * tess.n_v as usize
255 }
256}
257
258pub fn extract_cylinder_descriptor(
274 topo: &Topology,
275 face: FaceId,
276) -> Result<CylinderDescriptor, RenderError> {
277 let face_data = topo.face(face)?;
278 let FaceSurface::Cylinder(cyl) = face_data.surface() else {
279 return Err(RenderError::Operations(
280 brepkit_operations::OperationsError::InvalidInput {
281 reason: "extract_cylinder_descriptor: face is not a cylindrical surface".into(),
282 },
283 ));
284 };
285
286 let (v0, v1) = axial_range(topo, face, cyl)?;
287
288 let mut desc = CylinderDescriptor {
289 center: Point3::new(0.0, 0.0, 0.0),
290 axis_origin: cyl.origin(),
291 axis: cyl.axis(),
292 x_ref: cyl.x_axis(),
293 y_ref: cyl.y_axis(),
294 radius: cyl.radius(),
295 v0,
296 v1,
297 u0: 0.0,
298 u1: TAU,
299 };
300 let (min, max) = desc.aabb();
301 desc.center = Point3::new(
302 (min.x() + max.x()) * 0.5,
303 (min.y() + max.y()) * 0.5,
304 (min.z() + max.z()) * 0.5,
305 );
306 Ok(desc)
307}
308
309fn axial_range(
312 topo: &Topology,
313 face: FaceId,
314 cyl: &CylindricalSurface,
315) -> Result<(f64, f64), RenderError> {
316 let face_data = topo.face(face)?;
317 let wire = topo.wire(face_data.outer_wire())?;
318 let axis = cyl.axis();
319 let origin = cyl.origin();
320
321 let mut min_v = f64::INFINITY;
322 let mut max_v = f64::NEG_INFINITY;
323 for oe in wire.edges() {
324 let edge = topo.edge(oe.edge())?;
325 for vid in [edge.start(), edge.end()] {
326 let p = topo.vertex(vid)?.point();
327 let v = axis.dot(p - origin);
328 min_v = min_v.min(v);
329 max_v = max_v.max(v);
330 }
331 }
332 if !(min_v.is_finite() && max_v.is_finite()) || (max_v - min_v).abs() < f64::EPSILON {
333 return Err(RenderError::Operations(
334 brepkit_operations::OperationsError::InvalidInput {
335 reason: "extract_cylinder_descriptor: degenerate axial range on cylinder face"
336 .into(),
337 },
338 ));
339 }
340 Ok((min_v, max_v))
341}
342
343#[repr(C)]
348#[derive(Debug, Clone, Copy, Pod, Zeroable)]
349struct GpuDescriptor {
350 center: [f32; 3],
351 radius: f32,
352 axis_origin: [f32; 3],
353 v0: f32,
354 axis: [f32; 3],
355 v1: f32,
356 x_ref: [f32; 3],
357 u0: f32,
358 y_ref: [f32; 3],
359 u1: f32,
360 n_u: u32,
361 n_v: u32,
362 face_id: u32,
363 full: u32,
367}
368
369#[allow(clippy::too_many_lines)]
384pub fn render_cylinder_compute_offscreen(
385 desc: &CylinderDescriptor,
386 tess: TessFactor,
387 face_id: u32,
388 cam: &Camera,
389 opts: &RenderOpts,
390) -> Result<RenderOutput, RenderError> {
391 if opts.width == 0 || opts.height == 0 {
392 return Err(RenderError::InvalidSize {
393 width: opts.width,
394 height: opts.height,
395 });
396 }
397
398 let tess = TessFactor::new(tess.n_u, tess.n_v);
403
404 let (_adapter, device, queue) = pipeline::acquire_offscreen_device()?;
405
406 let max = device.limits().max_texture_dimension_2d;
409 if opts.width > max || opts.height > max {
410 return Err(RenderError::SizeTooLarge {
411 width: opts.width,
412 height: opts.height,
413 max,
414 });
415 }
416
417 let full = (desc.u1 - desc.u0 - TAU).abs() < 1.0e-6;
424 let cols = if full { tess.n_u } else { tess.n_u + 1 };
425 let rows = tess.n_v + 1;
426 let vertex_count = u64::from(cols) * u64::from(rows);
429 let index_count = u64::from(tess.n_u) * u64::from(tess.n_v) * 6;
430 let index_count_u32 = u32::try_from(index_count).unwrap_or(u32::MAX);
434 let vert_bytes = vertex_count * WORDS_PER_VERT * 4; let index_bytes = index_count * 4;
436
437 #[allow(clippy::cast_possible_truncation)]
439 let gpu_desc = GpuDescriptor {
440 center: pt_f32(desc.center),
441 radius: desc.radius as f32,
442 axis_origin: pt_f32(desc.axis_origin),
443 v0: desc.v0 as f32,
444 axis: vec_f32(desc.axis),
445 v1: desc.v1 as f32,
446 x_ref: vec_f32(desc.x_ref),
447 u0: desc.u0 as f32,
448 y_ref: vec_f32(desc.y_ref),
449 u1: desc.u1 as f32,
450 n_u: tess.n_u,
451 n_v: tess.n_v,
452 face_id,
453 full: u32::from(full),
454 };
455 let desc_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
456 label: Some("cylinder descriptor"),
457 contents: bytemuck::bytes_of(&gpu_desc),
458 usage: wgpu::BufferUsages::UNIFORM,
459 });
460
461 let vertex_buf = device.create_buffer(&wgpu::BufferDescriptor {
463 label: Some("compute vertices"),
464 size: vert_bytes,
465 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
466 mapped_at_creation: false,
467 });
468 let index_buf = device.create_buffer(&wgpu::BufferDescriptor {
469 label: Some("compute indices"),
470 size: index_bytes,
471 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDEX,
472 mapped_at_creation: false,
473 });
474
475 let compute_shader =
477 device.create_shader_module(wgpu::include_wgsl!("../shaders/quadric_mesh.wgsl"));
478 let compute_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
479 label: Some("compute mesher layout"),
480 entries: &[
481 wgpu::BindGroupLayoutEntry {
482 binding: 0,
483 visibility: wgpu::ShaderStages::COMPUTE,
484 ty: wgpu::BindingType::Buffer {
485 ty: wgpu::BufferBindingType::Uniform,
486 has_dynamic_offset: false,
487 min_binding_size: None,
488 },
489 count: None,
490 },
491 storage_entry(1),
492 storage_entry(2),
493 ],
494 });
495 let compute_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
496 label: Some("compute mesher bind group"),
497 layout: &compute_bgl,
498 entries: &[
499 wgpu::BindGroupEntry {
500 binding: 0,
501 resource: desc_buf.as_entire_binding(),
502 },
503 wgpu::BindGroupEntry {
504 binding: 1,
505 resource: vertex_buf.as_entire_binding(),
506 },
507 wgpu::BindGroupEntry {
508 binding: 2,
509 resource: index_buf.as_entire_binding(),
510 },
511 ],
512 });
513 let compute_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
514 label: Some("compute pipeline layout"),
515 bind_group_layouts: &[Some(&compute_bgl)],
516 immediate_size: 0,
517 });
518 let vertex_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
519 label: Some("cylinder vertex mesher"),
520 layout: Some(&compute_layout),
521 module: &compute_shader,
522 entry_point: Some("cs_vertices"),
523 compilation_options: wgpu::PipelineCompilationOptions::default(),
524 cache: None,
525 });
526 let index_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
527 label: Some("cylinder index mesher"),
528 layout: Some(&compute_layout),
529 module: &compute_shader,
530 entry_point: Some("cs_indices"),
531 compilation_options: wgpu::PipelineCompilationOptions::default(),
532 cache: None,
533 });
534
535 let draw = build_draw_resources(&device, desc, cam, opts);
537
538 let (width, height) = (opts.width, opts.height);
540 let targets = RenderTargets::new(&device, width, height);
541
542 let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
544 label: Some("compute + draw encoder"),
545 });
546 {
547 let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
548 label: Some("cylinder mesher"),
549 timestamp_writes: None,
550 });
551 cpass.set_bind_group(0, &compute_bind_group, &[]);
552 let groups_x = cols.div_ceil(8).max(1);
553 let groups_y = rows.div_ceil(8).max(1);
554 cpass.set_pipeline(&vertex_pipeline);
555 cpass.dispatch_workgroups(groups_x, groups_y, 1);
556 let igx = tess.n_u.div_ceil(8).max(1);
557 let igy = tess.n_v.div_ceil(8).max(1);
558 cpass.set_pipeline(&index_pipeline);
559 cpass.dispatch_workgroups(igx, igy, 1);
560 }
561
562 {
563 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
564 label: Some("compute mesh pass"),
565 color_attachments: &[
566 Some(wgpu::RenderPassColorAttachment {
567 view: &targets.color_view,
568 depth_slice: None,
569 resolve_target: None,
570 ops: wgpu::Operations {
571 load: wgpu::LoadOp::Clear(wgpu::Color {
572 r: f64::from(opts.background[0]),
573 g: f64::from(opts.background[1]),
574 b: f64::from(opts.background[2]),
575 a: f64::from(opts.background[3]),
576 }),
577 store: wgpu::StoreOp::Store,
578 },
579 }),
580 Some(wgpu::RenderPassColorAttachment {
581 view: &targets.id_view,
582 depth_slice: None,
583 resolve_target: None,
584 ops: wgpu::Operations {
585 load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
586 store: wgpu::StoreOp::Store,
587 },
588 }),
589 ],
590 depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
591 view: &targets.depth_view,
592 depth_ops: Some(wgpu::Operations {
593 load: wgpu::LoadOp::Clear(1.0),
594 store: wgpu::StoreOp::Store,
595 }),
596 stencil_ops: None,
597 }),
598 timestamp_writes: None,
599 occlusion_query_set: None,
600 multiview_mask: None,
601 });
602 pass.set_bind_group(0, &draw.bind_group, &[]);
603 pass.set_pipeline(&draw.pipeline);
604 pass.set_vertex_buffer(0, vertex_buf.slice(..));
605 pass.set_index_buffer(index_buf.slice(..), wgpu::IndexFormat::Uint32);
606 pass.draw_indexed(0..index_count_u32, 0, 0..1);
607 }
608
609 let color_bpr = pipeline::padded_bytes_per_row(width, 4);
610 let id_bpr = pipeline::padded_bytes_per_row(width, 4);
611 let color_readback = device.create_buffer(&wgpu::BufferDescriptor {
612 label: Some("color readback"),
613 size: u64::from(color_bpr) * u64::from(height),
614 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
615 mapped_at_creation: false,
616 });
617 let id_readback = device.create_buffer(&wgpu::BufferDescriptor {
618 label: Some("id readback"),
619 size: u64::from(id_bpr) * u64::from(height),
620 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
621 mapped_at_creation: false,
622 });
623 let extent = wgpu::Extent3d {
624 width,
625 height,
626 depth_or_array_layers: 1,
627 };
628 encoder.copy_texture_to_buffer(
629 wgpu::TexelCopyTextureInfo {
630 texture: &targets.color_tex,
631 mip_level: 0,
632 origin: wgpu::Origin3d::ZERO,
633 aspect: wgpu::TextureAspect::All,
634 },
635 wgpu::TexelCopyBufferInfo {
636 buffer: &color_readback,
637 layout: wgpu::TexelCopyBufferLayout {
638 offset: 0,
639 bytes_per_row: Some(color_bpr),
640 rows_per_image: Some(height),
641 },
642 },
643 extent,
644 );
645 encoder.copy_texture_to_buffer(
646 wgpu::TexelCopyTextureInfo {
647 texture: &targets.id_tex,
648 mip_level: 0,
649 origin: wgpu::Origin3d::ZERO,
650 aspect: wgpu::TextureAspect::All,
651 },
652 wgpu::TexelCopyBufferInfo {
653 buffer: &id_readback,
654 layout: wgpu::TexelCopyBufferLayout {
655 offset: 0,
656 bytes_per_row: Some(id_bpr),
657 rows_per_image: Some(height),
658 },
659 },
660 extent,
661 );
662
663 queue.submit(Some(encoder.finish()));
664
665 let color_bytes = pipeline::map_and_read(&device, &color_readback)?;
666 let id_bytes = pipeline::map_and_read(&device, &id_readback)?;
667 let color = pipeline::unpad_to_rgba(&color_bytes, width, height, color_bpr);
668 let id_buffer = pipeline::unpad_to_u32(&id_bytes, width, height, id_bpr);
669
670 Ok(RenderOutput {
671 color,
672 id_buffer,
673 width,
674 height,
675 })
676}
677
678pub fn render_cylinder_compute_screen_lod(
691 desc: &CylinderDescriptor,
692 face_id: u32,
693 cam: &Camera,
694 opts: &RenderOpts,
695 target_px: f64,
696) -> Result<RenderOutput, RenderError> {
697 let tess = screen_space_tess_factor(desc, cam, (opts.width, opts.height), target_px);
698 render_cylinder_compute_offscreen(desc, tess, face_id, cam, opts)
699}
700
701fn storage_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
703 wgpu::BindGroupLayoutEntry {
704 binding,
705 visibility: wgpu::ShaderStages::COMPUTE,
706 ty: wgpu::BindingType::Buffer {
707 ty: wgpu::BufferBindingType::Storage { read_only: false },
708 has_dynamic_offset: false,
709 min_binding_size: None,
710 },
711 count: None,
712 }
713}
714
715struct DrawResources {
717 pipeline: wgpu::RenderPipeline,
718 bind_group: wgpu::BindGroup,
719}
720
721fn build_draw_resources(
725 device: &wgpu::Device,
726 desc: &CylinderDescriptor,
727 cam: &Camera,
728 opts: &RenderOpts,
729) -> DrawResources {
730 let view_proj = crate::camera::view_proj_rtc(cam, desc.center);
731 let view_dir = cam.view_direction();
732 #[allow(clippy::cast_possible_truncation)]
733 let globals = pipeline::Globals {
734 view_proj,
735 view_dir: [
736 view_dir.x() as f32,
737 view_dir.y() as f32,
738 view_dir.z() as f32,
739 0.0,
740 ],
741 ambient: opts.ambient,
742 selected_id: 0,
743 _pad: [0.0; 2],
744 };
745 let globals_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
746 label: Some("globals"),
747 contents: bytemuck::bytes_of(&globals),
748 usage: wgpu::BufferUsages::UNIFORM,
749 });
750 let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
751 label: Some("globals layout"),
752 entries: &[wgpu::BindGroupLayoutEntry {
753 binding: 0,
754 visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
755 ty: wgpu::BindingType::Buffer {
756 ty: wgpu::BufferBindingType::Uniform,
757 has_dynamic_offset: false,
758 min_binding_size: None,
759 },
760 count: None,
761 }],
762 });
763 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
764 label: Some("globals bind group"),
765 layout: &bgl,
766 entries: &[wgpu::BindGroupEntry {
767 binding: 0,
768 resource: globals_buf.as_entire_binding(),
769 }],
770 });
771 let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
772 label: Some("draw pipeline layout"),
773 bind_group_layouts: &[Some(&bgl)],
774 immediate_size: 0,
775 });
776 let shader = device.create_shader_module(wgpu::include_wgsl!("../shaders/mesh.wgsl"));
777 let color_targets = [
778 Some(wgpu::ColorTargetState {
779 format: pipeline::COLOR_FORMAT_OFFSCREEN,
780 blend: None,
781 write_mask: wgpu::ColorWrites::ALL,
782 }),
783 Some(wgpu::ColorTargetState {
784 format: pipeline::ID_FORMAT,
785 blend: None,
786 write_mask: wgpu::ColorWrites::ALL,
787 }),
788 ];
789 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
790 label: Some("compute mesh draw pipeline"),
791 layout: Some(&layout),
792 vertex: wgpu::VertexState {
793 module: &shader,
794 entry_point: Some("vs_main"),
795 buffers: &[Some(wgpu::VertexBufferLayout {
796 array_stride: 28, step_mode: wgpu::VertexStepMode::Vertex,
798 attributes: &[
799 wgpu::VertexAttribute {
800 format: wgpu::VertexFormat::Float32x3,
801 offset: 0,
802 shader_location: 0,
803 },
804 wgpu::VertexAttribute {
805 format: wgpu::VertexFormat::Float32x3,
806 offset: 12,
807 shader_location: 1,
808 },
809 wgpu::VertexAttribute {
810 format: wgpu::VertexFormat::Uint32,
811 offset: 24,
812 shader_location: 2,
813 },
814 ],
815 })],
816 compilation_options: wgpu::PipelineCompilationOptions::default(),
817 },
818 primitive: wgpu::PrimitiveState {
819 topology: wgpu::PrimitiveTopology::TriangleList,
820 cull_mode: None,
821 ..Default::default()
822 },
823 depth_stencil: Some(wgpu::DepthStencilState {
824 format: pipeline::DEPTH_FORMAT,
825 depth_write_enabled: Some(true),
826 depth_compare: Some(wgpu::CompareFunction::Less),
827 stencil: wgpu::StencilState::default(),
828 bias: wgpu::DepthBiasState::default(),
829 }),
830 multisample: wgpu::MultisampleState::default(),
831 fragment: Some(wgpu::FragmentState {
832 module: &shader,
833 entry_point: Some("fs_main"),
834 targets: &color_targets,
835 compilation_options: wgpu::PipelineCompilationOptions::default(),
836 }),
837 multiview_mask: None,
838 cache: None,
839 });
840 DrawResources {
841 pipeline,
842 bind_group,
843 }
844}
845
846struct RenderTargets {
848 color_tex: wgpu::Texture,
849 id_tex: wgpu::Texture,
850 color_view: wgpu::TextureView,
851 depth_view: wgpu::TextureView,
852 id_view: wgpu::TextureView,
853}
854
855impl RenderTargets {
856 fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
857 let extent = wgpu::Extent3d {
858 width,
859 height,
860 depth_or_array_layers: 1,
861 };
862 let color_tex = device.create_texture(&wgpu::TextureDescriptor {
863 label: Some("color target"),
864 size: extent,
865 mip_level_count: 1,
866 sample_count: 1,
867 dimension: wgpu::TextureDimension::D2,
868 format: pipeline::COLOR_FORMAT_OFFSCREEN,
869 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
870 view_formats: &[],
871 });
872 let depth_tex = device.create_texture(&wgpu::TextureDescriptor {
873 label: Some("depth target"),
874 size: extent,
875 mip_level_count: 1,
876 sample_count: 1,
877 dimension: wgpu::TextureDimension::D2,
878 format: pipeline::DEPTH_FORMAT,
879 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
880 view_formats: &[],
881 });
882 let id_tex = device.create_texture(&wgpu::TextureDescriptor {
883 label: Some("id target"),
884 size: extent,
885 mip_level_count: 1,
886 sample_count: 1,
887 dimension: wgpu::TextureDimension::D2,
888 format: pipeline::ID_FORMAT,
889 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
890 view_formats: &[],
891 });
892 let color_view = color_tex.create_view(&wgpu::TextureViewDescriptor::default());
893 let depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default());
894 let id_view = id_tex.create_view(&wgpu::TextureViewDescriptor::default());
895 Self {
896 color_tex,
897 id_tex,
898 color_view,
899 depth_view,
900 id_view,
901 }
902 }
903}
904
905#[allow(clippy::cast_possible_truncation)]
906fn vec_f32(v: Vec3) -> [f32; 3] {
907 [v.x() as f32, v.y() as f32, v.z() as f32]
908}
909
910#[allow(clippy::cast_possible_truncation)]
911fn pt_f32(p: Point3) -> [f32; 3] {
912 [p.x() as f32, p.y() as f32, p.z() as f32]
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918
919 #[test]
920 fn tess_factor_clamps_below_minimum() {
921 let t = TessFactor::new(0, 0);
922 assert_eq!(t.n_u, 3, "n_u floors at 3 (degenerate below)");
923 assert_eq!(t.n_v, 1, "n_v floors at 1");
924 }
925
926 #[test]
927 fn tess_factor_clamps_above_maximum() {
928 let t = TessFactor::new(u32::MAX, u32::MAX);
929 assert_eq!(t.n_u, MAX_TESS, "n_u caps at MAX_TESS");
930 assert_eq!(t.n_v, MAX_TESS, "n_v caps at MAX_TESS");
931 }
932
933 #[test]
934 fn tess_factor_passes_through_valid_range() {
935 let t = TessFactor::new(48, 4);
936 assert_eq!((t.n_u, t.n_v), (48, 4));
937 }
938
939 #[test]
940 fn max_tess_keeps_index_and_vertex_counts_within_u32() {
941 let n = u64::from(MAX_TESS);
945 let cols = n + 1; let rows = n + 1;
947 let vertex_count = cols * rows;
948 let index_count = n * n * 6;
949 assert!(
950 u32::try_from(vertex_count).is_ok(),
951 "vertex_count {vertex_count} exceeds u32"
952 );
953 assert!(
954 u32::try_from(index_count).is_ok(),
955 "index_count {index_count} exceeds u32"
956 );
957 assert!(
960 u32::try_from(vertex_count * WORDS_PER_VERT).is_ok(),
961 "vertex word count exceeds u32"
962 );
963 }
964
965 fn unit_cylinder(radius: f64) -> CylinderDescriptor {
967 CylinderDescriptor {
968 center: Point3::new(0.0, 0.0, 0.0),
969 axis_origin: Point3::new(0.0, 0.0, -1.0),
970 axis: Vec3::new(0.0, 0.0, 1.0),
971 x_ref: Vec3::new(1.0, 0.0, 0.0),
972 y_ref: Vec3::new(0.0, 1.0, 0.0),
973 radius,
974 v0: 0.0,
975 v1: 2.0,
976 u0: 0.0,
977 u1: TAU,
978 }
979 }
980
981 fn camera_at(dist: f64) -> Camera {
983 Camera {
984 eye: Point3::new(dist, 0.0, 0.0),
985 target: Point3::new(0.0, 0.0, 0.0),
986 up: Vec3::new(0.0, 0.0, 1.0),
987 fov_y: 45.0_f64.to_radians(),
988 aspect: 1.0,
989 near: 0.1,
990 far: dist * 10.0,
991 }
992 }
993
994 #[test]
995 fn screen_lod_increases_when_closer() {
996 let desc = unit_cylinder(5.0);
997 let viewport = (512, 512);
998 let near = screen_space_tess_factor(&desc, &camera_at(20.0), viewport, 0.5);
999 let far = screen_space_tess_factor(&desc, &camera_at(200.0), viewport, 0.5);
1000 assert!(
1001 near.n_u > far.n_u,
1002 "closer camera should subdivide more: near {} far {}",
1003 near.n_u,
1004 far.n_u
1005 );
1006 assert_eq!(near.n_v, 1, "ruled axial direction stays at 1");
1007 assert_eq!(far.n_v, 1);
1008 }
1009
1010 #[test]
1011 fn screen_lod_increases_with_radius() {
1012 let viewport = (512, 512);
1013 let cam = camera_at(50.0);
1014 let small = screen_space_tess_factor(&unit_cylinder(2.0), &cam, viewport, 0.5);
1015 let large = screen_space_tess_factor(&unit_cylinder(40.0), &cam, viewport, 0.5);
1016 assert!(
1017 large.n_u > small.n_u,
1018 "larger projected radius should subdivide more: small {} large {}",
1019 small.n_u,
1020 large.n_u
1021 );
1022 }
1023
1024 #[test]
1025 fn screen_lod_floors_at_minimum_when_subpixel() {
1026 let desc = unit_cylinder(0.01);
1029 let t = screen_space_tess_factor(&desc, &camera_at(5_000.0), (256, 256), 0.5);
1030 assert_eq!(t.n_u, 3, "sub-pixel cylinder floors at the minimum");
1031 }
1032
1033 #[test]
1034 fn screen_lod_tighter_budget_subdivides_more() {
1035 let desc = unit_cylinder(5.0);
1036 let cam = camera_at(40.0);
1037 let coarse = screen_space_tess_factor(&desc, &cam, (512, 512), 2.0);
1038 let fine = screen_space_tess_factor(&desc, &cam, (512, 512), 0.25);
1039 assert!(
1040 fine.n_u > coarse.n_u,
1041 "a tighter pixel budget should subdivide more: coarse {} fine {}",
1042 coarse.n_u,
1043 fine.n_u
1044 );
1045 }
1046
1047 #[test]
1048 fn screen_lod_handles_degenerate_inputs() {
1049 let desc = unit_cylinder(5.0);
1050 let viewport = (512, 512);
1051
1052 let t0 = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, 0.0);
1054 assert_eq!(
1055 t0.n_u, MAX_TESS,
1056 "zero pixel budget requests the maximum LOD"
1057 );
1058 let t_nan = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, f64::NAN);
1059 assert_eq!(t_nan.n_u, MAX_TESS, "NaN budget falls back to maximum");
1060 }
1061
1062 #[test]
1063 fn screen_lod_engulfing_camera_requests_maximum() {
1064 let desc = unit_cylinder(5.0);
1069 let viewport = (512, 512);
1070 let mut on_center = camera_at(40.0);
1071 on_center.eye = desc.center;
1072 assert_eq!(
1073 screen_space_tess_factor(&desc, &on_center, viewport, 0.5).n_u,
1074 MAX_TESS,
1075 "a camera engulfed by the cylinder (depth 0) must request the maximum LOD"
1076 );
1077 }
1078
1079 #[test]
1080 fn screen_lod_clamps_extreme_fov_to_bounded_high_lod() {
1081 let desc = unit_cylinder(5.0);
1085 let viewport = (512, 512);
1086 let mut tiny_fov = camera_at(40.0);
1087 tiny_fov.fov_y = 1.0e-12;
1088 let normal = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, 0.5);
1089 let zoomed = screen_space_tess_factor(&desc, &tiny_fov, viewport, 0.5);
1090 assert!(
1091 zoomed.n_u > normal.n_u,
1092 "extreme zoom should subdivide far more than the normal fov: zoomed {} normal {}",
1093 zoomed.n_u,
1094 normal.n_u
1095 );
1096 }
1097
1098 #[test]
1099 fn screen_lod_behind_camera_floors_at_minimum() {
1100 let viewport = (512, 512);
1104 let mut desc = unit_cylinder(5.0);
1105 let cam = camera_at(40.0); desc.center = Point3::new(80.0, 0.0, 0.0); desc.axis_origin = Point3::new(80.0, 0.0, -1.0);
1108 let depth_is_negative = cam.view_direction().dot(desc.center - cam.eye) < 0.0;
1109 assert!(
1110 depth_is_negative,
1111 "test setup: center should be behind the camera"
1112 );
1113 assert_eq!(
1114 screen_space_tess_factor(&desc, &cam, viewport, 0.5).n_u,
1115 3,
1116 "a cylinder behind the camera floors at the minimum LOD"
1117 );
1118 }
1119}