use std::f64::consts::TAU;
use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;
use brepkit_math::surfaces::CylindricalSurface;
use brepkit_math::vec::{Point3, Vec3};
use brepkit_topology::Topology;
use brepkit_topology::face::{FaceId, FaceSurface};
use crate::camera::Camera;
use crate::error::RenderError;
use crate::pipeline;
use crate::{RenderOpts, RenderOutput};
const MAX_TESS: u32 = 16_384;
const WORDS_PER_VERT: u64 = 7;
#[derive(Debug, Clone, Copy)]
pub struct TessFactor {
pub n_u: u32,
pub n_v: u32,
}
impl TessFactor {
#[must_use]
pub fn new(n_u: u32, n_v: u32) -> Self {
Self {
n_u: n_u.clamp(3, MAX_TESS),
n_v: n_v.clamp(1, MAX_TESS),
}
}
}
pub const DEFAULT_TARGET_PX: f64 = 0.5;
#[must_use]
pub fn screen_space_tess_factor(
desc: &CylinderDescriptor,
cam: &Camera,
viewport: (u32, u32),
target_px: f64,
) -> TessFactor {
let n_u = angular_subdivisions_for_screen_error(desc, cam, viewport, target_px);
TessFactor::new(n_u, 1)
}
fn angular_subdivisions_for_screen_error(
desc: &CylinderDescriptor,
cam: &Camera,
viewport: (u32, u32),
target_px: f64,
) -> u32 {
let (_, height) = viewport;
let fov_y = cam.fov_y.clamp(1.0e-4, std::f64::consts::PI - 1.0e-4);
let half_fov_tan = (fov_y * 0.5).tan();
let depth = cam.view_direction().dot(desc.center - cam.eye);
if !(target_px.is_finite() && target_px > 0.0) {
return MAX_TESS;
}
let r_px = desc.radius * (f64::from(height) * 0.5) / (depth * half_fov_tan);
if r_px.is_infinite() && r_px > 0.0 {
return MAX_TESS;
}
if !(r_px.is_finite() && r_px > 0.0) {
return 3;
}
let ratio = (target_px / r_px).clamp(0.0, 2.0);
let theta = (1.0 - ratio).acos(); if !(theta.is_finite() && theta > 0.0) {
return 3;
}
let n = (std::f64::consts::PI / theta).ceil();
if n >= f64::from(MAX_TESS) {
MAX_TESS
} else {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let v = n as u32;
v
}
}
#[derive(Debug, Clone, Copy)]
pub struct CylinderDescriptor {
pub center: Point3,
pub axis_origin: Point3,
pub axis: Vec3,
pub x_ref: Vec3,
pub y_ref: Vec3,
pub radius: f64,
pub v0: f64,
pub v1: f64,
pub u0: f64,
pub u1: f64,
}
impl CylinderDescriptor {
#[must_use]
pub fn evaluate(&self, u: f64, v: f64) -> Point3 {
let radial = self.x_ref * (self.radius * u.cos()) + self.y_ref * (self.radius * u.sin());
self.axis_origin + radial + self.axis * v
}
fn aabb(&self) -> (Point3, Point3) {
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
let samples = 64;
for k in 0..=samples {
let t = f64::from(k) / f64::from(samples);
let u = self.u0 + (self.u1 - self.u0) * t;
for &v in &[self.v0, self.v1] {
let p = self.evaluate(u, v);
let c = [p.x(), p.y(), p.z()];
for axis in 0..3 {
min[axis] = min[axis].min(c[axis]);
max[axis] = max[axis].max(c[axis]);
}
}
}
(
Point3::new(min[0], min[1], min[2]),
Point3::new(max[0], max[1], max[2]),
)
}
#[must_use]
pub fn triangle_count(tess: TessFactor) -> usize {
2 * tess.n_u as usize * tess.n_v as usize
}
}
pub fn extract_cylinder_descriptor(
topo: &Topology,
face: FaceId,
) -> Result<CylinderDescriptor, RenderError> {
let face_data = topo.face(face)?;
let FaceSurface::Cylinder(cyl) = face_data.surface() else {
return Err(RenderError::Operations(
brepkit_operations::OperationsError::InvalidInput {
reason: "extract_cylinder_descriptor: face is not a cylindrical surface".into(),
},
));
};
let (v0, v1) = axial_range(topo, face, cyl)?;
let mut desc = CylinderDescriptor {
center: Point3::new(0.0, 0.0, 0.0),
axis_origin: cyl.origin(),
axis: cyl.axis(),
x_ref: cyl.x_axis(),
y_ref: cyl.y_axis(),
radius: cyl.radius(),
v0,
v1,
u0: 0.0,
u1: TAU,
};
let (min, max) = desc.aabb();
desc.center = Point3::new(
(min.x() + max.x()) * 0.5,
(min.y() + max.y()) * 0.5,
(min.z() + max.z()) * 0.5,
);
Ok(desc)
}
fn axial_range(
topo: &Topology,
face: FaceId,
cyl: &CylindricalSurface,
) -> Result<(f64, f64), RenderError> {
let face_data = topo.face(face)?;
let wire = topo.wire(face_data.outer_wire())?;
let axis = cyl.axis();
let origin = cyl.origin();
let mut min_v = f64::INFINITY;
let mut max_v = f64::NEG_INFINITY;
for oe in wire.edges() {
let edge = topo.edge(oe.edge())?;
for vid in [edge.start(), edge.end()] {
let p = topo.vertex(vid)?.point();
let v = axis.dot(p - origin);
min_v = min_v.min(v);
max_v = max_v.max(v);
}
}
if !(min_v.is_finite() && max_v.is_finite()) || (max_v - min_v).abs() < f64::EPSILON {
return Err(RenderError::Operations(
brepkit_operations::OperationsError::InvalidInput {
reason: "extract_cylinder_descriptor: degenerate axial range on cylinder face"
.into(),
},
));
}
Ok((min_v, max_v))
}
#[repr(C)]
#[derive(Debug, Clone, Copy, Pod, Zeroable)]
struct GpuDescriptor {
center: [f32; 3],
radius: f32,
axis_origin: [f32; 3],
v0: f32,
axis: [f32; 3],
v1: f32,
x_ref: [f32; 3],
u0: f32,
y_ref: [f32; 3],
u1: f32,
n_u: u32,
n_v: u32,
face_id: u32,
full: u32,
}
#[allow(clippy::too_many_lines)]
pub fn render_cylinder_compute_offscreen(
desc: &CylinderDescriptor,
tess: TessFactor,
face_id: u32,
cam: &Camera,
opts: &RenderOpts,
) -> Result<RenderOutput, RenderError> {
if opts.width == 0 || opts.height == 0 {
return Err(RenderError::InvalidSize {
width: opts.width,
height: opts.height,
});
}
let tess = TessFactor::new(tess.n_u, tess.n_v);
let instance = wgpu::Instance::default();
let (_adapter, device, queue) = pipeline::acquire_device(&instance, None)?;
let max = device.limits().max_texture_dimension_2d;
if opts.width > max || opts.height > max {
return Err(RenderError::SizeTooLarge {
width: opts.width,
height: opts.height,
max,
});
}
let full = (desc.u1 - desc.u0 - TAU).abs() < 1.0e-6;
let cols = if full { tess.n_u } else { tess.n_u + 1 };
let rows = tess.n_v + 1;
let vertex_count = u64::from(cols) * u64::from(rows);
let index_count = u64::from(tess.n_u) * u64::from(tess.n_v) * 6;
let index_count_u32 = u32::try_from(index_count).unwrap_or(u32::MAX);
let vert_bytes = vertex_count * WORDS_PER_VERT * 4; let index_bytes = index_count * 4;
#[allow(clippy::cast_possible_truncation)]
let gpu_desc = GpuDescriptor {
center: pt_f32(desc.center),
radius: desc.radius as f32,
axis_origin: pt_f32(desc.axis_origin),
v0: desc.v0 as f32,
axis: vec_f32(desc.axis),
v1: desc.v1 as f32,
x_ref: vec_f32(desc.x_ref),
u0: desc.u0 as f32,
y_ref: vec_f32(desc.y_ref),
u1: desc.u1 as f32,
n_u: tess.n_u,
n_v: tess.n_v,
face_id,
full: u32::from(full),
};
let desc_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("cylinder descriptor"),
contents: bytemuck::bytes_of(&gpu_desc),
usage: wgpu::BufferUsages::UNIFORM,
});
let vertex_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("compute vertices"),
size: vert_bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
mapped_at_creation: false,
});
let index_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("compute indices"),
size: index_bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDEX,
mapped_at_creation: false,
});
let compute_shader =
device.create_shader_module(wgpu::include_wgsl!("../shaders/quadric_mesh.wgsl"));
let compute_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("compute mesher layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
storage_entry(1),
storage_entry(2),
],
});
let compute_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("compute mesher bind group"),
layout: &compute_bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: desc_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: vertex_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: index_buf.as_entire_binding(),
},
],
});
let compute_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("compute pipeline layout"),
bind_group_layouts: &[Some(&compute_bgl)],
immediate_size: 0,
});
let vertex_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("cylinder vertex mesher"),
layout: Some(&compute_layout),
module: &compute_shader,
entry_point: Some("cs_vertices"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
cache: None,
});
let index_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("cylinder index mesher"),
layout: Some(&compute_layout),
module: &compute_shader,
entry_point: Some("cs_indices"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
cache: None,
});
let draw = build_draw_resources(&device, desc, cam, opts);
let (width, height) = (opts.width, opts.height);
let targets = RenderTargets::new(&device, width, height);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("compute + draw encoder"),
});
{
let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("cylinder mesher"),
timestamp_writes: None,
});
cpass.set_bind_group(0, &compute_bind_group, &[]);
let groups_x = cols.div_ceil(8).max(1);
let groups_y = rows.div_ceil(8).max(1);
cpass.set_pipeline(&vertex_pipeline);
cpass.dispatch_workgroups(groups_x, groups_y, 1);
let igx = tess.n_u.div_ceil(8).max(1);
let igy = tess.n_v.div_ceil(8).max(1);
cpass.set_pipeline(&index_pipeline);
cpass.dispatch_workgroups(igx, igy, 1);
}
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("compute mesh pass"),
color_attachments: &[
Some(wgpu::RenderPassColorAttachment {
view: &targets.color_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: f64::from(opts.background[0]),
g: f64::from(opts.background[1]),
b: f64::from(opts.background[2]),
a: f64::from(opts.background[3]),
}),
store: wgpu::StoreOp::Store,
},
}),
Some(wgpu::RenderPassColorAttachment {
view: &targets.id_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
}),
],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &targets.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_bind_group(0, &draw.bind_group, &[]);
pass.set_pipeline(&draw.pipeline);
pass.set_vertex_buffer(0, vertex_buf.slice(..));
pass.set_index_buffer(index_buf.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..index_count_u32, 0, 0..1);
}
let color_bpr = pipeline::padded_bytes_per_row(width, 4);
let id_bpr = pipeline::padded_bytes_per_row(width, 4);
let color_readback = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("color readback"),
size: u64::from(color_bpr) * u64::from(height),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let id_readback = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("id readback"),
size: u64::from(id_bpr) * u64::from(height),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let extent = wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
};
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &targets.color_tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &color_readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(color_bpr),
rows_per_image: Some(height),
},
},
extent,
);
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &targets.id_tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &id_readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(id_bpr),
rows_per_image: Some(height),
},
},
extent,
);
queue.submit(Some(encoder.finish()));
let color_bytes = pipeline::map_and_read(&device, &color_readback)?;
let id_bytes = pipeline::map_and_read(&device, &id_readback)?;
let color = pipeline::unpad_to_rgba(&color_bytes, width, height, color_bpr);
let id_buffer = pipeline::unpad_to_u32(&id_bytes, width, height, id_bpr);
Ok(RenderOutput {
color,
id_buffer,
width,
height,
})
}
pub fn render_cylinder_compute_screen_lod(
desc: &CylinderDescriptor,
face_id: u32,
cam: &Camera,
opts: &RenderOpts,
target_px: f64,
) -> Result<RenderOutput, RenderError> {
let tess = screen_space_tess_factor(desc, cam, (opts.width, opts.height), target_px);
render_cylinder_compute_offscreen(desc, tess, face_id, cam, opts)
}
fn storage_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
struct DrawResources {
pipeline: wgpu::RenderPipeline,
bind_group: wgpu::BindGroup,
}
fn build_draw_resources(
device: &wgpu::Device,
desc: &CylinderDescriptor,
cam: &Camera,
opts: &RenderOpts,
) -> DrawResources {
let view_proj = crate::camera::view_proj_rtc(cam, desc.center);
let view_dir = cam.view_direction();
#[allow(clippy::cast_possible_truncation)]
let globals = pipeline::Globals {
view_proj,
view_dir: [
view_dir.x() as f32,
view_dir.y() as f32,
view_dir.z() as f32,
0.0,
],
ambient: opts.ambient,
selected_id: 0,
_pad: [0.0; 2],
};
let globals_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("globals"),
contents: bytemuck::bytes_of(&globals),
usage: wgpu::BufferUsages::UNIFORM,
});
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("globals layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("globals bind group"),
layout: &bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: globals_buf.as_entire_binding(),
}],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("draw pipeline layout"),
bind_group_layouts: &[Some(&bgl)],
immediate_size: 0,
});
let shader = device.create_shader_module(wgpu::include_wgsl!("../shaders/mesh.wgsl"));
let color_targets = [
Some(wgpu::ColorTargetState {
format: pipeline::COLOR_FORMAT_OFFSCREEN,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
}),
Some(wgpu::ColorTargetState {
format: pipeline::ID_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
}),
];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("compute mesh draw pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: 28, step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x3,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x3,
offset: 12,
shader_location: 1,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Uint32,
offset: 24,
shader_location: 2,
},
],
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: pipeline::DEPTH_FORMAT,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &color_targets,
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview_mask: None,
cache: None,
});
DrawResources {
pipeline,
bind_group,
}
}
struct RenderTargets {
color_tex: wgpu::Texture,
id_tex: wgpu::Texture,
color_view: wgpu::TextureView,
depth_view: wgpu::TextureView,
id_view: wgpu::TextureView,
}
impl RenderTargets {
fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
let extent = wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
};
let color_tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("color target"),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: pipeline::COLOR_FORMAT_OFFSCREEN,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let depth_tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("depth target"),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: pipeline::DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let id_tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("id target"),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: pipeline::ID_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let color_view = color_tex.create_view(&wgpu::TextureViewDescriptor::default());
let depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default());
let id_view = id_tex.create_view(&wgpu::TextureViewDescriptor::default());
Self {
color_tex,
id_tex,
color_view,
depth_view,
id_view,
}
}
}
#[allow(clippy::cast_possible_truncation)]
fn vec_f32(v: Vec3) -> [f32; 3] {
[v.x() as f32, v.y() as f32, v.z() as f32]
}
#[allow(clippy::cast_possible_truncation)]
fn pt_f32(p: Point3) -> [f32; 3] {
[p.x() as f32, p.y() as f32, p.z() as f32]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tess_factor_clamps_below_minimum() {
let t = TessFactor::new(0, 0);
assert_eq!(t.n_u, 3, "n_u floors at 3 (degenerate below)");
assert_eq!(t.n_v, 1, "n_v floors at 1");
}
#[test]
fn tess_factor_clamps_above_maximum() {
let t = TessFactor::new(u32::MAX, u32::MAX);
assert_eq!(t.n_u, MAX_TESS, "n_u caps at MAX_TESS");
assert_eq!(t.n_v, MAX_TESS, "n_v caps at MAX_TESS");
}
#[test]
fn tess_factor_passes_through_valid_range() {
let t = TessFactor::new(48, 4);
assert_eq!((t.n_u, t.n_v), (48, 4));
}
#[test]
fn max_tess_keeps_index_and_vertex_counts_within_u32() {
let n = u64::from(MAX_TESS);
let cols = n + 1; let rows = n + 1;
let vertex_count = cols * rows;
let index_count = n * n * 6;
assert!(
u32::try_from(vertex_count).is_ok(),
"vertex_count {vertex_count} exceeds u32"
);
assert!(
u32::try_from(index_count).is_ok(),
"index_count {index_count} exceeds u32"
);
assert!(
u32::try_from(vertex_count * WORDS_PER_VERT).is_ok(),
"vertex word count exceeds u32"
);
}
fn unit_cylinder(radius: f64) -> CylinderDescriptor {
CylinderDescriptor {
center: Point3::new(0.0, 0.0, 0.0),
axis_origin: Point3::new(0.0, 0.0, -1.0),
axis: Vec3::new(0.0, 0.0, 1.0),
x_ref: Vec3::new(1.0, 0.0, 0.0),
y_ref: Vec3::new(0.0, 1.0, 0.0),
radius,
v0: 0.0,
v1: 2.0,
u0: 0.0,
u1: TAU,
}
}
fn camera_at(dist: f64) -> Camera {
Camera {
eye: Point3::new(dist, 0.0, 0.0),
target: Point3::new(0.0, 0.0, 0.0),
up: Vec3::new(0.0, 0.0, 1.0),
fov_y: 45.0_f64.to_radians(),
aspect: 1.0,
near: 0.1,
far: dist * 10.0,
}
}
#[test]
fn screen_lod_increases_when_closer() {
let desc = unit_cylinder(5.0);
let viewport = (512, 512);
let near = screen_space_tess_factor(&desc, &camera_at(20.0), viewport, 0.5);
let far = screen_space_tess_factor(&desc, &camera_at(200.0), viewport, 0.5);
assert!(
near.n_u > far.n_u,
"closer camera should subdivide more: near {} far {}",
near.n_u,
far.n_u
);
assert_eq!(near.n_v, 1, "ruled axial direction stays at 1");
assert_eq!(far.n_v, 1);
}
#[test]
fn screen_lod_increases_with_radius() {
let viewport = (512, 512);
let cam = camera_at(50.0);
let small = screen_space_tess_factor(&unit_cylinder(2.0), &cam, viewport, 0.5);
let large = screen_space_tess_factor(&unit_cylinder(40.0), &cam, viewport, 0.5);
assert!(
large.n_u > small.n_u,
"larger projected radius should subdivide more: small {} large {}",
small.n_u,
large.n_u
);
}
#[test]
fn screen_lod_floors_at_minimum_when_subpixel() {
let desc = unit_cylinder(0.01);
let t = screen_space_tess_factor(&desc, &camera_at(5_000.0), (256, 256), 0.5);
assert_eq!(t.n_u, 3, "sub-pixel cylinder floors at the minimum");
}
#[test]
fn screen_lod_tighter_budget_subdivides_more() {
let desc = unit_cylinder(5.0);
let cam = camera_at(40.0);
let coarse = screen_space_tess_factor(&desc, &cam, (512, 512), 2.0);
let fine = screen_space_tess_factor(&desc, &cam, (512, 512), 0.25);
assert!(
fine.n_u > coarse.n_u,
"a tighter pixel budget should subdivide more: coarse {} fine {}",
coarse.n_u,
fine.n_u
);
}
#[test]
fn screen_lod_handles_degenerate_inputs() {
let desc = unit_cylinder(5.0);
let viewport = (512, 512);
let t0 = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, 0.0);
assert_eq!(
t0.n_u, MAX_TESS,
"zero pixel budget requests the maximum LOD"
);
let t_nan = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, f64::NAN);
assert_eq!(t_nan.n_u, MAX_TESS, "NaN budget falls back to maximum");
}
#[test]
fn screen_lod_engulfing_camera_requests_maximum() {
let desc = unit_cylinder(5.0);
let viewport = (512, 512);
let mut on_center = camera_at(40.0);
on_center.eye = desc.center;
assert_eq!(
screen_space_tess_factor(&desc, &on_center, viewport, 0.5).n_u,
MAX_TESS,
"a camera engulfed by the cylinder (depth 0) must request the maximum LOD"
);
}
#[test]
fn screen_lod_clamps_extreme_fov_to_bounded_high_lod() {
let desc = unit_cylinder(5.0);
let viewport = (512, 512);
let mut tiny_fov = camera_at(40.0);
tiny_fov.fov_y = 1.0e-12;
let normal = screen_space_tess_factor(&desc, &camera_at(40.0), viewport, 0.5);
let zoomed = screen_space_tess_factor(&desc, &tiny_fov, viewport, 0.5);
assert!(
zoomed.n_u > normal.n_u,
"extreme zoom should subdivide far more than the normal fov: zoomed {} normal {}",
zoomed.n_u,
normal.n_u
);
}
#[test]
fn screen_lod_behind_camera_floors_at_minimum() {
let viewport = (512, 512);
let mut desc = unit_cylinder(5.0);
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);
let depth_is_negative = cam.view_direction().dot(desc.center - cam.eye) < 0.0;
assert!(
depth_is_negative,
"test setup: center should be behind the camera"
);
assert_eq!(
screen_space_tess_factor(&desc, &cam, viewport, 0.5).n_u,
3,
"a cylinder behind the camera floors at the minimum LOD"
);
}
}