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 instance = wgpu::Instance::default();
405 let (_adapter, device, queue) = pipeline::acquire_device(&instance, None)?;
406
407 let max = device.limits().max_texture_dimension_2d;
410 if opts.width > max || opts.height > max {
411 return Err(RenderError::SizeTooLarge {
412 width: opts.width,
413 height: opts.height,
414 max,
415 });
416 }
417
418 let full = (desc.u1 - desc.u0 - TAU).abs() < 1.0e-6;
425 let cols = if full { tess.n_u } else { tess.n_u + 1 };
426 let rows = tess.n_v + 1;
427 let vertex_count = u64::from(cols) * u64::from(rows);
430 let index_count = u64::from(tess.n_u) * u64::from(tess.n_v) * 6;
431 let index_count_u32 = u32::try_from(index_count).unwrap_or(u32::MAX);
435 let vert_bytes = vertex_count * WORDS_PER_VERT * 4; let index_bytes = index_count * 4;
437
438 #[allow(clippy::cast_possible_truncation)]
440 let gpu_desc = GpuDescriptor {
441 center: pt_f32(desc.center),
442 radius: desc.radius as f32,
443 axis_origin: pt_f32(desc.axis_origin),
444 v0: desc.v0 as f32,
445 axis: vec_f32(desc.axis),
446 v1: desc.v1 as f32,
447 x_ref: vec_f32(desc.x_ref),
448 u0: desc.u0 as f32,
449 y_ref: vec_f32(desc.y_ref),
450 u1: desc.u1 as f32,
451 n_u: tess.n_u,
452 n_v: tess.n_v,
453 face_id,
454 full: u32::from(full),
455 };
456 let desc_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
457 label: Some("cylinder descriptor"),
458 contents: bytemuck::bytes_of(&gpu_desc),
459 usage: wgpu::BufferUsages::UNIFORM,
460 });
461
462 let vertex_buf = device.create_buffer(&wgpu::BufferDescriptor {
464 label: Some("compute vertices"),
465 size: vert_bytes,
466 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
467 mapped_at_creation: false,
468 });
469 let index_buf = device.create_buffer(&wgpu::BufferDescriptor {
470 label: Some("compute indices"),
471 size: index_bytes,
472 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDEX,
473 mapped_at_creation: false,
474 });
475
476 let compute_shader =
478 device.create_shader_module(wgpu::include_wgsl!("../shaders/quadric_mesh.wgsl"));
479 let compute_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
480 label: Some("compute mesher layout"),
481 entries: &[
482 wgpu::BindGroupLayoutEntry {
483 binding: 0,
484 visibility: wgpu::ShaderStages::COMPUTE,
485 ty: wgpu::BindingType::Buffer {
486 ty: wgpu::BufferBindingType::Uniform,
487 has_dynamic_offset: false,
488 min_binding_size: None,
489 },
490 count: None,
491 },
492 storage_entry(1),
493 storage_entry(2),
494 ],
495 });
496 let compute_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
497 label: Some("compute mesher bind group"),
498 layout: &compute_bgl,
499 entries: &[
500 wgpu::BindGroupEntry {
501 binding: 0,
502 resource: desc_buf.as_entire_binding(),
503 },
504 wgpu::BindGroupEntry {
505 binding: 1,
506 resource: vertex_buf.as_entire_binding(),
507 },
508 wgpu::BindGroupEntry {
509 binding: 2,
510 resource: index_buf.as_entire_binding(),
511 },
512 ],
513 });
514 let compute_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
515 label: Some("compute pipeline layout"),
516 bind_group_layouts: &[Some(&compute_bgl)],
517 immediate_size: 0,
518 });
519 let vertex_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
520 label: Some("cylinder vertex mesher"),
521 layout: Some(&compute_layout),
522 module: &compute_shader,
523 entry_point: Some("cs_vertices"),
524 compilation_options: wgpu::PipelineCompilationOptions::default(),
525 cache: None,
526 });
527 let index_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
528 label: Some("cylinder index mesher"),
529 layout: Some(&compute_layout),
530 module: &compute_shader,
531 entry_point: Some("cs_indices"),
532 compilation_options: wgpu::PipelineCompilationOptions::default(),
533 cache: None,
534 });
535
536 let draw = build_draw_resources(&device, desc, cam, opts);
538
539 let (width, height) = (opts.width, opts.height);
541 let targets = RenderTargets::new(&device, width, height);
542
543 let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
545 label: Some("compute + draw encoder"),
546 });
547 {
548 let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
549 label: Some("cylinder mesher"),
550 timestamp_writes: None,
551 });
552 cpass.set_bind_group(0, &compute_bind_group, &[]);
553 let groups_x = cols.div_ceil(8).max(1);
554 let groups_y = rows.div_ceil(8).max(1);
555 cpass.set_pipeline(&vertex_pipeline);
556 cpass.dispatch_workgroups(groups_x, groups_y, 1);
557 let igx = tess.n_u.div_ceil(8).max(1);
558 let igy = tess.n_v.div_ceil(8).max(1);
559 cpass.set_pipeline(&index_pipeline);
560 cpass.dispatch_workgroups(igx, igy, 1);
561 }
562
563 {
564 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
565 label: Some("compute mesh pass"),
566 color_attachments: &[
567 Some(wgpu::RenderPassColorAttachment {
568 view: &targets.color_view,
569 depth_slice: None,
570 resolve_target: None,
571 ops: wgpu::Operations {
572 load: wgpu::LoadOp::Clear(wgpu::Color {
573 r: f64::from(opts.background[0]),
574 g: f64::from(opts.background[1]),
575 b: f64::from(opts.background[2]),
576 a: f64::from(opts.background[3]),
577 }),
578 store: wgpu::StoreOp::Store,
579 },
580 }),
581 Some(wgpu::RenderPassColorAttachment {
582 view: &targets.id_view,
583 depth_slice: None,
584 resolve_target: None,
585 ops: wgpu::Operations {
586 load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
587 store: wgpu::StoreOp::Store,
588 },
589 }),
590 ],
591 depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
592 view: &targets.depth_view,
593 depth_ops: Some(wgpu::Operations {
594 load: wgpu::LoadOp::Clear(1.0),
595 store: wgpu::StoreOp::Store,
596 }),
597 stencil_ops: None,
598 }),
599 timestamp_writes: None,
600 occlusion_query_set: None,
601 multiview_mask: None,
602 });
603 pass.set_bind_group(0, &draw.bind_group, &[]);
604 pass.set_pipeline(&draw.pipeline);
605 pass.set_vertex_buffer(0, vertex_buf.slice(..));
606 pass.set_index_buffer(index_buf.slice(..), wgpu::IndexFormat::Uint32);
607 pass.draw_indexed(0..index_count_u32, 0, 0..1);
608 }
609
610 let color_bpr = pipeline::padded_bytes_per_row(width, 4);
611 let id_bpr = pipeline::padded_bytes_per_row(width, 4);
612 let color_readback = device.create_buffer(&wgpu::BufferDescriptor {
613 label: Some("color readback"),
614 size: u64::from(color_bpr) * u64::from(height),
615 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
616 mapped_at_creation: false,
617 });
618 let id_readback = device.create_buffer(&wgpu::BufferDescriptor {
619 label: Some("id readback"),
620 size: u64::from(id_bpr) * u64::from(height),
621 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
622 mapped_at_creation: false,
623 });
624 let extent = wgpu::Extent3d {
625 width,
626 height,
627 depth_or_array_layers: 1,
628 };
629 encoder.copy_texture_to_buffer(
630 wgpu::TexelCopyTextureInfo {
631 texture: &targets.color_tex,
632 mip_level: 0,
633 origin: wgpu::Origin3d::ZERO,
634 aspect: wgpu::TextureAspect::All,
635 },
636 wgpu::TexelCopyBufferInfo {
637 buffer: &color_readback,
638 layout: wgpu::TexelCopyBufferLayout {
639 offset: 0,
640 bytes_per_row: Some(color_bpr),
641 rows_per_image: Some(height),
642 },
643 },
644 extent,
645 );
646 encoder.copy_texture_to_buffer(
647 wgpu::TexelCopyTextureInfo {
648 texture: &targets.id_tex,
649 mip_level: 0,
650 origin: wgpu::Origin3d::ZERO,
651 aspect: wgpu::TextureAspect::All,
652 },
653 wgpu::TexelCopyBufferInfo {
654 buffer: &id_readback,
655 layout: wgpu::TexelCopyBufferLayout {
656 offset: 0,
657 bytes_per_row: Some(id_bpr),
658 rows_per_image: Some(height),
659 },
660 },
661 extent,
662 );
663
664 queue.submit(Some(encoder.finish()));
665
666 let color_bytes = pipeline::map_and_read(&device, &color_readback)?;
667 let id_bytes = pipeline::map_and_read(&device, &id_readback)?;
668 let color = pipeline::unpad_to_rgba(&color_bytes, width, height, color_bpr);
669 let id_buffer = pipeline::unpad_to_u32(&id_bytes, width, height, id_bpr);
670
671 Ok(RenderOutput {
672 color,
673 id_buffer,
674 width,
675 height,
676 })
677}
678
679pub fn render_cylinder_compute_screen_lod(
692 desc: &CylinderDescriptor,
693 face_id: u32,
694 cam: &Camera,
695 opts: &RenderOpts,
696 target_px: f64,
697) -> Result<RenderOutput, RenderError> {
698 let tess = screen_space_tess_factor(desc, cam, (opts.width, opts.height), target_px);
699 render_cylinder_compute_offscreen(desc, tess, face_id, cam, opts)
700}
701
702fn storage_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
704 wgpu::BindGroupLayoutEntry {
705 binding,
706 visibility: wgpu::ShaderStages::COMPUTE,
707 ty: wgpu::BindingType::Buffer {
708 ty: wgpu::BufferBindingType::Storage { read_only: false },
709 has_dynamic_offset: false,
710 min_binding_size: None,
711 },
712 count: None,
713 }
714}
715
716struct DrawResources {
718 pipeline: wgpu::RenderPipeline,
719 bind_group: wgpu::BindGroup,
720}
721
722fn build_draw_resources(
726 device: &wgpu::Device,
727 desc: &CylinderDescriptor,
728 cam: &Camera,
729 opts: &RenderOpts,
730) -> DrawResources {
731 let view_proj = crate::camera::view_proj_rtc(cam, desc.center);
732 let view_dir = cam.view_direction();
733 #[allow(clippy::cast_possible_truncation)]
734 let globals = pipeline::Globals {
735 view_proj,
736 view_dir: [
737 view_dir.x() as f32,
738 view_dir.y() as f32,
739 view_dir.z() as f32,
740 0.0,
741 ],
742 ambient: opts.ambient,
743 selected_id: 0,
744 _pad: [0.0; 2],
745 };
746 let globals_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
747 label: Some("globals"),
748 contents: bytemuck::bytes_of(&globals),
749 usage: wgpu::BufferUsages::UNIFORM,
750 });
751 let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
752 label: Some("globals layout"),
753 entries: &[wgpu::BindGroupLayoutEntry {
754 binding: 0,
755 visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
756 ty: wgpu::BindingType::Buffer {
757 ty: wgpu::BufferBindingType::Uniform,
758 has_dynamic_offset: false,
759 min_binding_size: None,
760 },
761 count: None,
762 }],
763 });
764 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
765 label: Some("globals bind group"),
766 layout: &bgl,
767 entries: &[wgpu::BindGroupEntry {
768 binding: 0,
769 resource: globals_buf.as_entire_binding(),
770 }],
771 });
772 let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
773 label: Some("draw pipeline layout"),
774 bind_group_layouts: &[Some(&bgl)],
775 immediate_size: 0,
776 });
777 let shader = device.create_shader_module(wgpu::include_wgsl!("../shaders/mesh.wgsl"));
778 let color_targets = [
779 Some(wgpu::ColorTargetState {
780 format: pipeline::COLOR_FORMAT_OFFSCREEN,
781 blend: None,
782 write_mask: wgpu::ColorWrites::ALL,
783 }),
784 Some(wgpu::ColorTargetState {
785 format: pipeline::ID_FORMAT,
786 blend: None,
787 write_mask: wgpu::ColorWrites::ALL,
788 }),
789 ];
790 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
791 label: Some("compute mesh draw pipeline"),
792 layout: Some(&layout),
793 vertex: wgpu::VertexState {
794 module: &shader,
795 entry_point: Some("vs_main"),
796 buffers: &[Some(wgpu::VertexBufferLayout {
797 array_stride: 28, step_mode: wgpu::VertexStepMode::Vertex,
799 attributes: &[
800 wgpu::VertexAttribute {
801 format: wgpu::VertexFormat::Float32x3,
802 offset: 0,
803 shader_location: 0,
804 },
805 wgpu::VertexAttribute {
806 format: wgpu::VertexFormat::Float32x3,
807 offset: 12,
808 shader_location: 1,
809 },
810 wgpu::VertexAttribute {
811 format: wgpu::VertexFormat::Uint32,
812 offset: 24,
813 shader_location: 2,
814 },
815 ],
816 })],
817 compilation_options: wgpu::PipelineCompilationOptions::default(),
818 },
819 primitive: wgpu::PrimitiveState {
820 topology: wgpu::PrimitiveTopology::TriangleList,
821 cull_mode: None,
822 ..Default::default()
823 },
824 depth_stencil: Some(wgpu::DepthStencilState {
825 format: pipeline::DEPTH_FORMAT,
826 depth_write_enabled: Some(true),
827 depth_compare: Some(wgpu::CompareFunction::Less),
828 stencil: wgpu::StencilState::default(),
829 bias: wgpu::DepthBiasState::default(),
830 }),
831 multisample: wgpu::MultisampleState::default(),
832 fragment: Some(wgpu::FragmentState {
833 module: &shader,
834 entry_point: Some("fs_main"),
835 targets: &color_targets,
836 compilation_options: wgpu::PipelineCompilationOptions::default(),
837 }),
838 multiview_mask: None,
839 cache: None,
840 });
841 DrawResources {
842 pipeline,
843 bind_group,
844 }
845}
846
847struct RenderTargets {
849 color_tex: wgpu::Texture,
850 id_tex: wgpu::Texture,
851 color_view: wgpu::TextureView,
852 depth_view: wgpu::TextureView,
853 id_view: wgpu::TextureView,
854}
855
856impl RenderTargets {
857 fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
858 let extent = wgpu::Extent3d {
859 width,
860 height,
861 depth_or_array_layers: 1,
862 };
863 let color_tex = device.create_texture(&wgpu::TextureDescriptor {
864 label: Some("color target"),
865 size: extent,
866 mip_level_count: 1,
867 sample_count: 1,
868 dimension: wgpu::TextureDimension::D2,
869 format: pipeline::COLOR_FORMAT_OFFSCREEN,
870 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
871 view_formats: &[],
872 });
873 let depth_tex = device.create_texture(&wgpu::TextureDescriptor {
874 label: Some("depth target"),
875 size: extent,
876 mip_level_count: 1,
877 sample_count: 1,
878 dimension: wgpu::TextureDimension::D2,
879 format: pipeline::DEPTH_FORMAT,
880 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
881 view_formats: &[],
882 });
883 let id_tex = device.create_texture(&wgpu::TextureDescriptor {
884 label: Some("id target"),
885 size: extent,
886 mip_level_count: 1,
887 sample_count: 1,
888 dimension: wgpu::TextureDimension::D2,
889 format: pipeline::ID_FORMAT,
890 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
891 view_formats: &[],
892 });
893 let color_view = color_tex.create_view(&wgpu::TextureViewDescriptor::default());
894 let depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default());
895 let id_view = id_tex.create_view(&wgpu::TextureViewDescriptor::default());
896 Self {
897 color_tex,
898 id_tex,
899 color_view,
900 depth_view,
901 id_view,
902 }
903 }
904}
905
906#[allow(clippy::cast_possible_truncation)]
907fn vec_f32(v: Vec3) -> [f32; 3] {
908 [v.x() as f32, v.y() as f32, v.z() as f32]
909}
910
911#[allow(clippy::cast_possible_truncation)]
912fn pt_f32(p: Point3) -> [f32; 3] {
913 [p.x() as f32, p.y() as f32, p.z() as f32]
914}
915
916#[cfg(test)]
917mod tests {
918 use super::*;
919
920 #[test]
921 fn tess_factor_clamps_below_minimum() {
922 let t = TessFactor::new(0, 0);
923 assert_eq!(t.n_u, 3, "n_u floors at 3 (degenerate below)");
924 assert_eq!(t.n_v, 1, "n_v floors at 1");
925 }
926
927 #[test]
928 fn tess_factor_clamps_above_maximum() {
929 let t = TessFactor::new(u32::MAX, u32::MAX);
930 assert_eq!(t.n_u, MAX_TESS, "n_u caps at MAX_TESS");
931 assert_eq!(t.n_v, MAX_TESS, "n_v caps at MAX_TESS");
932 }
933
934 #[test]
935 fn tess_factor_passes_through_valid_range() {
936 let t = TessFactor::new(48, 4);
937 assert_eq!((t.n_u, t.n_v), (48, 4));
938 }
939
940 #[test]
941 fn max_tess_keeps_index_and_vertex_counts_within_u32() {
942 let n = u64::from(MAX_TESS);
946 let cols = n + 1; let rows = n + 1;
948 let vertex_count = cols * rows;
949 let index_count = n * n * 6;
950 assert!(
951 u32::try_from(vertex_count).is_ok(),
952 "vertex_count {vertex_count} exceeds u32"
953 );
954 assert!(
955 u32::try_from(index_count).is_ok(),
956 "index_count {index_count} exceeds u32"
957 );
958 assert!(
961 u32::try_from(vertex_count * WORDS_PER_VERT).is_ok(),
962 "vertex word count exceeds u32"
963 );
964 }
965
966 fn unit_cylinder(radius: f64) -> CylinderDescriptor {
968 CylinderDescriptor {
969 center: Point3::new(0.0, 0.0, 0.0),
970 axis_origin: Point3::new(0.0, 0.0, -1.0),
971 axis: Vec3::new(0.0, 0.0, 1.0),
972 x_ref: Vec3::new(1.0, 0.0, 0.0),
973 y_ref: Vec3::new(0.0, 1.0, 0.0),
974 radius,
975 v0: 0.0,
976 v1: 2.0,
977 u0: 0.0,
978 u1: TAU,
979 }
980 }
981
982 fn camera_at(dist: f64) -> Camera {
984 Camera {
985 eye: Point3::new(dist, 0.0, 0.0),
986 target: Point3::new(0.0, 0.0, 0.0),
987 up: Vec3::new(0.0, 0.0, 1.0),
988 fov_y: 45.0_f64.to_radians(),
989 aspect: 1.0,
990 near: 0.1,
991 far: dist * 10.0,
992 }
993 }
994
995 #[test]
996 fn screen_lod_increases_when_closer() {
997 let desc = unit_cylinder(5.0);
998 let viewport = (512, 512);
999 let near = screen_space_tess_factor(&desc, &camera_at(20.0), viewport, 0.5);
1000 let far = screen_space_tess_factor(&desc, &camera_at(200.0), viewport, 0.5);
1001 assert!(
1002 near.n_u > far.n_u,
1003 "closer camera should subdivide more: near {} far {}",
1004 near.n_u,
1005 far.n_u
1006 );
1007 assert_eq!(near.n_v, 1, "ruled axial direction stays at 1");
1008 assert_eq!(far.n_v, 1);
1009 }
1010
1011 #[test]
1012 fn screen_lod_increases_with_radius() {
1013 let viewport = (512, 512);
1014 let cam = camera_at(50.0);
1015 let small = screen_space_tess_factor(&unit_cylinder(2.0), &cam, viewport, 0.5);
1016 let large = screen_space_tess_factor(&unit_cylinder(40.0), &cam, viewport, 0.5);
1017 assert!(
1018 large.n_u > small.n_u,
1019 "larger projected radius should subdivide more: small {} large {}",
1020 small.n_u,
1021 large.n_u
1022 );
1023 }
1024
1025 #[test]
1026 fn screen_lod_floors_at_minimum_when_subpixel() {
1027 let desc = unit_cylinder(0.01);
1030 let t = screen_space_tess_factor(&desc, &camera_at(5_000.0), (256, 256), 0.5);
1031 assert_eq!(t.n_u, 3, "sub-pixel cylinder floors at the minimum");
1032 }
1033
1034 #[test]
1035 fn screen_lod_tighter_budget_subdivides_more() {
1036 let desc = unit_cylinder(5.0);
1037 let cam = camera_at(40.0);
1038 let coarse = screen_space_tess_factor(&desc, &cam, (512, 512), 2.0);
1039 let fine = screen_space_tess_factor(&desc, &cam, (512, 512), 0.25);
1040 assert!(
1041 fine.n_u > coarse.n_u,
1042 "a tighter pixel budget should subdivide more: coarse {} fine {}",
1043 coarse.n_u,
1044 fine.n_u
1045 );
1046 }
1047
1048 #[test]
1049 fn screen_lod_handles_degenerate_inputs() {
1050 let desc = unit_cylinder(5.0);
1051 let viewport = (512, 512);
1052
1053 let t0 = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, 0.0);
1055 assert_eq!(
1056 t0.n_u, MAX_TESS,
1057 "zero pixel budget requests the maximum LOD"
1058 );
1059 let t_nan = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, f64::NAN);
1060 assert_eq!(t_nan.n_u, MAX_TESS, "NaN budget falls back to maximum");
1061 }
1062
1063 #[test]
1064 fn screen_lod_engulfing_camera_requests_maximum() {
1065 let desc = unit_cylinder(5.0);
1070 let viewport = (512, 512);
1071 let mut on_center = camera_at(40.0);
1072 on_center.eye = desc.center;
1073 assert_eq!(
1074 screen_space_tess_factor(&desc, &on_center, viewport, 0.5).n_u,
1075 MAX_TESS,
1076 "a camera engulfed by the cylinder (depth 0) must request the maximum LOD"
1077 );
1078 }
1079
1080 #[test]
1081 fn screen_lod_clamps_extreme_fov_to_bounded_high_lod() {
1082 let desc = unit_cylinder(5.0);
1086 let viewport = (512, 512);
1087 let mut tiny_fov = camera_at(40.0);
1088 tiny_fov.fov_y = 1.0e-12;
1089 let normal = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, 0.5);
1090 let zoomed = screen_space_tess_factor(&desc, &tiny_fov, viewport, 0.5);
1091 assert!(
1092 zoomed.n_u > normal.n_u,
1093 "extreme zoom should subdivide far more than the normal fov: zoomed {} normal {}",
1094 zoomed.n_u,
1095 normal.n_u
1096 );
1097 }
1098
1099 #[test]
1100 fn screen_lod_behind_camera_floors_at_minimum() {
1101 let viewport = (512, 512);
1105 let mut desc = unit_cylinder(5.0);
1106 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);
1109 let depth_is_negative = cam.view_direction().dot(desc.center - cam.eye) < 0.0;
1110 assert!(
1111 depth_is_negative,
1112 "test setup: center should be behind the camera"
1113 );
1114 assert_eq!(
1115 screen_space_tess_factor(&desc, &cam, viewport, 0.5).n_u,
1116 3,
1117 "a cylinder behind the camera floors at the minimum LOD"
1118 );
1119 }
1120}